안드로이드 스튜디오 설치


언리얼 에디터 경로연결

SDK, NDK, JDK

SDK : 최종 결과물 만드는 역할 (Android API)

NDK : C++ -> Android용 바이너리(.so)로 변환 (게임 코드 컴파일)

JDK : 빌드 실행 엔진 (Gradle 실행, 전체 Android 빌드 프로세스 담당) 


타겟 플랫폼(Target Platforms)  설치


빌드

 

 

 

[Unreal Engine] -> C++ 코드(NDK) => .so 생성 -> Android 프로젝트 구성(SDK) => (JDK + Gradle) = APK 생성

 

 

#ASTC

텍스처 압축 방식(이미지 압축 포맷)

리플렉션이란 프로그램이 런타임에 자기 자신을 조사하는 기능이다.

C++에는 리플렉션이란 기능이 없다. 그래서 언리얼이 리플렉션을 제공한다.

아무래도 개발편의성 측면에서 필요한 시스템이라 판단해서 넣지 않았을까..?

 

언리얼에 리플렉션은, 런타임과 동시에 에디터에서 타입 정보를 보일 수 있게 하고, 조작도 할 수 있게 해주는 시스템이다.

자주쓰는 [UCLASS],[USTRUCT],[UENUM],[UPROPERTY],[UFUCTION]같은 매크로가 대표적인 예이다.

 

언리얼에 리플렉션에는 가능한것들이 에디터에서 변수 노출/수정, 디테일 패널, 직렬화 / 세이브|로드, 블루프린트 노출, GC추적, FindFunction, ProcessEvent같은 동적 호출/ 바인딩 등등이 있다.

 

UHT(Unreal Header Tool) 이 매크로들을 분석하여 코드를 자동으로 생성한다.

이때 생성된 코드가 타입에 관련된 메타정보이다.

 

UHT에 정의를 찾아보면

UHT는 언리얼 빌드 과정에서 C++헤더를 스캔하여, 리플렉션이 필요한 타입의 메타데이터 코드를 자동으로 생성하는 전처리 도구이며 C++컴파일러 이전 단계에서 .h(헤더파일)을 분석하여 정보를 생성한다.

 

그런데 UHT는 어떻게 이런 매크로를 탐지하여 코드를 자동으로 생성할까?

빌드 시 build.cs에서 UBT(Unreal Build Tool)가 실행된다. 그런 다음에 UHT는 위에 언급한 매크로처럼 작성된 패턴을 찾는다. 

이 매크로들은 C++매크로 역할과 UHT인식용 표식 역할을 동시에한다.

 

UCLASS()
class AMyActor : public AActor

 

UHT는 이런 코드를 보고, 이 클래스는 리플렉션 대상이구나. 판단하게된다.

 

그럼 UHT가 생성하는 코드는 무엇일까?

UHT는 .generated.h파일을 생성한다. (ex MyActor.generated.h)

이 안에는 다음과 같은 코드들이 들어간다. 

타입 등록코드(클래스 이름, 프로퍼티 목록, 함수 목록을 엔진 내부 리플렉션 시스템에 등록), 

UPROPERTY : 변수 타입, 이름, 오프셋, 메타 태그 GC추적 여부

UFUNCTION : 메타정보, 호출래핑

RPC / Replication 관련 코드 : 어떤 변수가 어떤 조건에서 어떤 네트워크 정책으로 동기화되는지 정의

GC 지원 코드 : UObject포인터를 UPROPERTY로 표시했는지, 참조 그래프에서 포함시켜 Mark & Sweep 대상으로 만듦

 

즉, UHT는 빌드 시점에 헤더 파일을 검사해서 UCLASS, UPROPERTY같은 매크로가 붙은 타입을 찾아 리플렉션용 메타데이터와 접착 코드(C++과 엔진의 공용 시스템을 연결해주는 중간 코드)를 생성한다.

이렇게 생성된 .generated.h, gen.cpp에는 타입 등록, 프로퍼티 정보, RPC/블루프린트 호출 래퍼,GC추적을 위한 코드가 포함된다.

 

이를 통해 언리얼은 C++타입을 에디터와 런타임에서 동적으로 다룰 수 있게된다.

옵션 세팅 UI

게임에서 필요한, 기본적인 옵션을 세팅할 수 있는 UI를 넣어보았다.

옵션내용은 사용자가 입력한 내용그대로 데이터로 저장되고, 다시 되돌리는 리셋 구조로 만들었다.

DataAsset -> SubSystem -> SaveGame -> Widget

 


동작

1. 게임 실행 시 자동로드

- DataAsset 기본값

- SaveGame 사용자 값 저장, 둘 중에서 자동으로 선택

2. UI 반영 (UI 오픈 시)

- 그래픽옵션, 사운드 옵션, 카메라 옵션 Save데이터로 반영

3. 저장, 초기화

- SaveGame 저장

- DataAsset기반 초기값으로 복귀


FPC_OptionData - 옵션 데이터 모델


OptionConfigDataAsset – 프로젝트 기본값 저장

- DefaultOption : 프로젝트 초기값

- SvaeOption : 런타임 중 실시간 값 저장 가능

기획에서 자주 건드리는 영역은 DataAsset기반이 좋다.


OptionSubsystem – 옵션 로드/저장

초기화 흐름

void UPC_OptionSubsystem::Initialize(...)
{
    LoadOption();
    ApplyGraphicsOptions();
    ApplyAudioOptions();
}

- 게임 시작 시 SaveGame -> 없으면 DataAsset (디폴트)

- 읽은 즉시 옵션 적용

 

저장

void UPC_OptionSubsystem::SaveOption()
{
	UPC_OptionSaveGame* SaveObj = Cast<UPC_OptionSaveGame>(
	UGameplayStatics::CreateSaveGameObject(UPC_OptionSaveGame::StaticClass()));

	SaveObj->SavedOption = CurrentOption;
	UGameplayStatics::SaveGameToSlot(SaveObj, SaveSlotName, SaveUserIndex);

	if(ConfigAsset)
	{
		ConfigAsset->SaveOption = CurrentOption;
	}
}

 

리셋

void UPC_OptionSubsystem::RestOption()
{
	UPC_OptionSaveGame* SaveObj = Cast<UPC_OptionSaveGame>(
		UGameplayStatics::CreateSaveGameObject(UPC_OptionSaveGame::StaticClass()));

	if (ConfigAsset)
	{
		SaveObj->SavedOption = ConfigAsset->DefaultOption;
		CurrentOption = ConfigAsset->DefaultOption;
	}

	UGameplayStatics::SaveGameToSlot(SaveObj, SaveSlotName, SaveUserIndex);
}

 

적용

void UPC_OptionSubsystem::ApplyAndSaveOption(const FPC_OptionData& NewOption)
{
	CurrentOption = NewOption;

	ApplyGraphicsOptions();
	ApplyAudioOptions();

	SaveOption();
}

OptionSettingWidget – UI 연결

초기화

//TextBlock, Slider, CheckBox 세팅
// ....
    if (ResetButton)
		ResetButton->OnClicked.AddDynamic(this, &UPC_OptionSettingWidget::UPC_OptionSettingWidget::RestSetting);
	
	if (UGameInstance* GameInstance = GetGameInstance())
	{
		if (UPC_OptionSubsystem* subsystem = GameInstance->GetSubsystem<UPC_OptionSubsystem>())
		{
			OptionSubsystem = subsystem;
		}
	}

	if (OptionSubsystem.IsValid())
	{
		RefreshSetting();
	}

- 텍스트블럭,체크박스, 슬라이더 세팅(현재 데이터로)

- 현재 옵션Config 혹은 디폴트 옵션Config 맞게 적용

 

저장버튼

void UPC_OptionSettingWidget::SaveSetting()
{
	OptionSubsystem->ApplyAndSaveOption(ApplyOption);
}

 

- ApplyOption은 현재 사용자가 인터렉션으로 적용중인 최신 데이터

 

리셋버튼

void UPC_OptionSettingWidget::RestSetting()
{
	OptionSubsystem->RestOption();
	RefreshSetting();
}

 


구현결과

저장

로드

리셋

데이터반영

- ai 구성

- 타겟위치 타입을 리턴하는 서비스노드 생성

- 타겟 위치에따른 스킬발동처리

- 스킬 추가

- Near_l 왼쪽 발로 밝기

- Near_r 오른쪽 발로 밝기

- 스킬이펙트 추가 나이아가라, SkillPoseBoneName 각각 foot_l, foot_r 발로 밟는 스킬이니까 발에서 터지게

- enum SkillFxAttachType 추가 스킬 fx가 본 어디에서 터질지(본,본중간,본에 바닥..), 스킬 고도화및 다양화로 인해 데이터로 써줄수있게 하려고, 한가지더 핑이튀거나 시간차로 이펙트 위치가 공중에 ,이상한곳이 생길수있다. 그것보다는 아예 바닥으로 나오게 하는 보정 처리도 있음

- Camera Shake Type추가, 히트됐을때, 스킬발동시 무조건 

- 가지고있는 에셋이 심심하기에, 다른 파티클에 있는 모듈가져와서 겹쳐서 풍성하게 변경

비포
애프터

ControlRig이란

언리얼에서 스켈레탈 메시(본 구조)를 실시간으로 조작하거나 변형할 수 있게 해주는 리깅 시스템

 

목표

- 각 발의 본(foot_l, foot_r)기준으로 아래 방향 Trace -> 히트 z높이를 얻는다.

- 그 높이를 컨트롤(컨트롤 본)로 전달 -> Modify Transforms로 컨트롤 이동 -> 본이 따라감

- 마지막에 FullBodyIK로 사슬을 풀어 발이 바닥에  "짚도록" 계산

- 골반(pelvis)은 좌우 발 컨트롤의 더 낮은 z에 맞춰 살짝 내려 안정적인 무게중심을 만든다.

 

사용방법

1. ControlRig 생성

2. ImportHierarchy버튼을 누르면 어떤 매쉬를 적용할 것인지 선택 할 수 있다.

3. Foot Trace 함수 추가

- Name으로 'foot_l'을 세팅한다.

- Input에 Foot bone을 추가하고, 타입을 Rig Element Key로 변경한다.

- Rig Element Key란 : 계층의 항목(본/컨트롤/커브)을 타입 + 이름으로 식별하는 키

- Get Transform Bone :  (이름으로도 가져 올 수 있다.)

- FootBone으로부터 z값을 위에서 아래로 Trace처리

 

 

- Draw Line : Debug Line 확인

'foot_l' 부터 Trace처리 된 모습

 

4. ABP 적용

내 프로젝트 기준 ABP_Player에 마지막 Output 처리 전에 해당 ControlRig 노드를 추가 해준다.

- 이렇게하면 에디터 실행 한 후 디버깅이 가능하다.

콘솔 명령어 :  a.AnimNode.ControlRig.Debug 1

 

5. Trace된 Z 위치 (foot_l, foot_r)

6. Alpha Interpolate 적용 : 스무스하게

7. 컨트롤 : 본에 위치를 지정해 놓고 본에 위치를 바꾸기용

- Bone 위치 가져오기 : 방금 생성한 Control Bone으로 생성한 본에 위치를 가져올 수 가 있다.

- SetTransform으로 Bone 위치를 바꾼다.

- Modify Trasnforms을 이용해 컨트롤에 위치를 변경한다. : trace된 위치로 그러면 Bone이 따라 오니까.

 

컨트롤에 위치를 통해 Bone이 어디로 움직여야 할 곳인지 알 수 있다.

 

8. IK적용 

- FullBodyIk : 

- Root는 pelvis

- 앞서 구한 컨트롤 위치로 IK적용한다.

- Settings -> Root behavior : Pint To Input 처리

9. pelvis 

- 지금까지 작업만으로 끝나지 않는다. 결국 골반 위치도 변경해줘야한다.
- 골반위치는 Trace된 왼쪽 컨트롤과 오른쪽 컨틀롤중에 값이 더 작은 z위치로 설정해준다.

- pelvis 위치 적용

 

구현 완료

 

추가로

- 특수한 행동 :점프,기어다니기 중에서는 IK로직이 돌지 않도록 한다.

거대보스 - 피격 디버그

- 거대보스는 단일 캡슐로 피격 판단을 하면 부위 타격을 처리하기 힘들다. "어디에 맞았는지?"

- 부위별 피격 처리 : 다리 = 이동속도 디버프, 머리 = 크리티컬, 몸통 = 기본데미지 가 가능하도록

- 작업 속도 : 디버그로 바디 위치/크기를 한눈에 확인


핵심작업

1. 거대보스 BP 추가

- 캐릭터가 거대보스 다리사이로 지나가도록 그것을 분류할 수 있는 EnemyTable 변수추가,

BeginPlay()시에 캡슐컬라이더 통과 되도록

2. EnemyTable 플래그 추가

- HitPartUnit : 부위 타격이 가능한 Enemy인지

- NonPlayeralbeCharacter클래스에 디버그 함수 추가


설계 포인트

1. PhysicsAsset -> HitPart데이터 매핑

- 런타임에 사용 중인 스켈레탈 메시에서 PhysicsAsset참조

- FSoftObjectPath로 에셋 키를 만들고, 데이터 테이블 HitPartList에서 본 이름 가져옴

- 히트위치마다 디버그 컬러 매핑

- 장점 : 몬스터 에셋마다 테이블만 갈아끼우면 재사용

 

2) 본 위치에  Debug Draw 표시

- 본 인덱스 가져오기 : GetBoneIndex(BodySetup->BoneName)

- 구체, 캡슐

 

3) 디버그 드로잉

 

HitPart.Table

 

Control Rig IK

- 발 위치 보정 같은 로컬 리깅 로직은 ControlRig이 직관적이고 재사용성이 좋음.

- 작업 내용은 아래 정리

https://funfunhanblog.tistory.com/581


Hit Reaction설계

1. 요구사항

- 액션감 : 피격순간 Overlay Material(플래시/에미시브 등)로 타격감 부여

- 중복 방지 : 넉백/스턴 같은 CrowdControl공격에도 동일 오버레이터가 중복 트리거 되는 문제 방지

- 상태 예외 : 스킬 사용 중에는 에임 흔들림/히트리액션 실행x

2. FDamageType기반 필터링

- NormalDamageType일 때만 오버레이 적용

- CrowdControlDamageType혹은 별도 CC타입은 오버레이 미적용 (CC연출과 충돌 방지)

- FRadialDamageEvent(광역/폭팔) : 시각 정책에 따라

float APC_BaseCharacter::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator,
                                    AActor* DamageCauser)
{
    const float Damage = StatComponent->ApplyDamage(FinalDamage, DamageCauser, false);
    if (Damage > KINDA_SMALL_NUMBER)
    {
       if(DamageEvent.DamageTypeClass == UPC_NormalAttackDamageType::StaticClass())
       {
       			코드생략..
       }
     }

3. EnemyTable 확장

- HitReactionAnim : 히트 시 실행 될 애니메이션 추가

- MaterialOverlay

 

4. Enum EnemyStateType SkillUsing 추가

- 스킬 동작 중 이면 HitReact피하기 위해

 


문제 발생 해결 포인트

- 문제 CC 공격에도 오버레이가 재생되어 일반 공격에 들어가는 오버레이와 겹침

=> 해결 : DamageType으로 분기

- 문제 : 스킬 모션중에 피격 받음

=> EnemyState상태 추가로 스킬 공격중에는 HitAction처리 안하도록

문제 : IK 적용 후에도 정상작동 안함

=> pelvis 골반도 같이 이동 처리


구현 결과 IK

 


히트 리액션

 

데미지  플로터

데미지를 숫자로 시각화하는 데미지 플로터다. 공격이 성공 할때마다 월드 좌표를 기준으로 화면에 숫자가 뜨고, 점점 위로 떠오르며 사라지는 효과를 준다.


설계 개요

1. 3D 월드 좌표 -> 2D화면 좌표로 변환(ProjectWorldLocationToScreen 사용)

2. 플로터 위치가 화면 밖으로 안 나가게 클램프 처리

3. 위젯이 점점 커지며 떠오르고, 서서히 투명해지는 애니메이션

4. 모든 UI생성은  UISubsystem에서 일원화


구현 포인트

1. 월드 ->  뷰포트 좌표 변환

OwnerController->ProjectWorldLocationToScreen(SpawnWorldLocation, ScreenPos, true);

- true를 주면 플레이어 뷰포트 기준으로 좌표로 변환된다.

 

2. 화면에 안잘리도록 클램프

OwnerController->GetViewportSize(VX, VY);
SpawnPos.X = FMath::Clamp(SpawnPos.X, ScreenPadding, float(VX) - ScreenPadding);
SpawnPos.Y = FMath::Clamp(SpawnPos.Y, ScreenPadding, float(VY) - ScreenPadding);

- ScreenPadding만큼 여백을 주어 화면 가장자리에서 숫자가 안 잘리게 처리

 

3. DPI 스케일 제거

SetPositionInViewport(SmoothedScreenPos, /*bRemoveDPIScale=*/true);

- DPI스케일을 제거하면, GetViewportsize()와 같은 좌표계로 일관성이 유지된다.

 

4. 스무딩 이동 이징

SmoothedScreenPos = FMath::Vector2DInterpTo(SmoothedScreenPos, Desired, InDeltaTime, ScreenLerpSpeed);

- 프레임마다 부드럽게 이동시켜 플로터가 자연스럽게 올라감


코드 요약

void FPC_GameUtil::SpawnDamageFloater(ACharacter* DamageCharacter, int32 Damage)
{
	if (!DamageCharacter) return;

	UWorld* World = DamageCharacter->GetWorld();
	if (!World) return;

	auto* GI = World->GetGameInstance();
	if (!GI) return;

	APlayerController* PC = UGameplayStatics::GetPlayerController(World, 0);
	if (!PC) return;

	const float HalfHeight = DamageCharacter->GetCapsuleComponent()->GetScaledCapsuleHalfHeight();
	const FVector WorldPos = DamageCharacter->GetActorLocation() + FVector(0, 0, HalfHeight);

	if (auto* UISubsystem = GI->GetSubsystem<UPC_UISubsystem>())
	{
		if (auto* Floater = UISubsystem->CreateDamageFloater(DamageCharacter))
		{
			Floater->Init(Damage, WorldPos, PC);
		}
	}
}

 

- UISubsystem에서 데미지 플로터 클래스를 한 번만 등록해두면 이 함수만 호출해도 어디서든 동일한 방식으로 UI가 생성된다.


동작 흐름

1. 적 피격 -> 데미지 계산

2. 데미지 플로터 스폰

3. UISubsystem에서 CreateWidget()실행

4. UPC_DamgeFloaterWidget::Init()실행

5. 월드 좌표 기준으로 화면에 배치

6. 위로 상승 -> 페이드 아웃

 


정리

시스템 UI Subsystem + UserWidget
좌표계 월드 → 스크린 (ProjectWorldLocationToScreen)
배치 함수 SetPositionInViewport(bRemoveDPIScale = true)
핵심 효과 Scale In, Rise, Fade Out
수명 관리 ElapsedTime / LifeTime 기반 RemoveFromParent()

 


구현결과

 

왜 바꿨나?

기존 방식 : 매 Tick마다 "플레이어 주변 적"탐색

문제 : Tick마다 액터들에 거리 체크, 암살가능한 적인지, 불필요한 연산이 발생

목표 : 이벤트 기반으로 변경


방법

플레이어에 스피어 컴포넌트를 달고, Overlap이벤트로 근접 대상만 관리

전용 InteractionComponent를 만들어 Overlap/EndOverlap에 바인딩

콜리전에 들어온 액터만 후보 리스트에 넣고, 필요할 때만 최적 타겟을 계산


구현

- UStaticMeshComponent같은 본체말고, 전용 USphereComponent를 플레이어에게 추가

- 플레이어만 갖고 있는 클래스에 BeginPlay()에서 아래 두 함수 바인딩

if(!InteractionOverlapComponent->OnComponentBeginOverlap.IsAlreadyBound(InteractionComponent.Get(), &UPC_InteractionComponent::OnBeginOverlap))
    InteractionOverlapComponent->OnComponentBeginOverlap.AddDynamic(InteractionComponent.Get(), &UPC_InteractionComponent::OnBeginOverlap);

if(!InteractionOverlapComponent->OnComponentEndOverlap.IsAlreadyBound(InteractionComponent.Get(), &UPC_InteractionComponent::OnEndOverlap))
    InteractionOverlapComponent->OnComponentEndOverlap.AddDynamic(InteractionComponent.Get(), &UPC_InteractionComponent::OnEndOverlap);

 


후보 관리 & 최적 타겟

- BeginOverlap들어오면 후보 리스트에 담고, 그 후로 리스트중에 최적 타겟을 찾는다.

- EndOverlap되면 그 후보리스트에서 빠지도록 한다.

void UPC_InteractionComponent::OnBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
    UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    OverlappingActors.Add(OtherActor);
}

void UPC_InteractionComponent::OnEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
    UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
    OverlappingActors.Remove(OtherActor);

    if(IPC_CharacterWidgetInterface* CharacterWidgetInterface = Cast<IPC_CharacterWidgetInterface>(OtherActor))
    {
       CharacterWidgetInterface->OnSelectedAssassinateTarget(false);
    }

    if(OtherActor == AssassinateTarget)
    {
       AssassinateTarget = nullptr;
    }
}

 

- 카메라 방향기준으로 가장 각도가 작은 적을 최적 타겟으로 설정한다.


구현 결과

왜 이 작업을 했나?

전투가 붙고 몹이 쓰러진 뒤에도

. 락온이 계속잡히고

. 스킬 타겟팅이 시체를 겨냥하고,

. BT가 좀비처럼 돌며,

. CC(기절,슬로우)가 괜히 남아있는 버그.

사망은 이벤트가 아니라, "상태 전환 + 시스템 정리 트리거"라는 전제를 세우고, 한 번에 끝내는 공통 파이프를 만들었다.


목표

캐릭터 Base클래스에서 OnDead() : 

1) UI : 체력바 숨김

2) AI : BT중지

3) 상태 : Dead로 변경

4) CC : CrowdControlComponent->StopCC()

5) 타겟팅 시스템 : "Dead 배제"

6) 애님 ABP의 Dead 전용 시퀀스 실행(몽타주/스테이트)


구현 포인트

1) UI) 체력바 숨김

- 적 캐릭터의 WidgetComponent에 

check(WidgetComponent);
WidgetComponent->SetVisibility(false);

2) AI중지

StopTree()로 행동 트리 중지

if (AAIController* AIContoller = Cast<AAIController>(GetController()))
{
    UBehaviorTreeComponent* BTComponent = Cast<UBehaviorTreeComponent>(AIContoller->GetBrainComponent());
    if (BTComponent)   
    {
       BTComponent->StopTree();
    }
}

 

3) 상태 전환

ChangeState(EPC_EnemyStateType::Dead)

Dead진입 시 모든 입력/행동/피격 처리 루틴이 Early Return되도록 공통 처리

 

4) CC종료

CrowdControlComponent() => StopCC()

 

5) 애님 : Dead시퀀스

ABP 전용 상태/몽타주 실행


구현결과

새로운 원거리 스킬!

그동안 구현한 스킬들은 한 번 타격 후 종료되는 일회성 스킬 중심이었는데, 이번에는 조준 상태에서 유지되는 지속형 공격, 그리고 그 공격이 상대의 상태를 제어하는 시스템을 추가로 구현했다.

LOL 럼블


DOT스킬 설계 (AimOffset상태 기반)

- 새로운 스킬 타입 Dot추가

1) Exec 시작 시 캐릭터 손 Bone FX Attach형태로 생성되고 ,Exec이 끝나면 자동으로 제거 되도록

2) FX가 생성과 동시에 Collision 생성하여 타겟 검출

- 콜리전 추가

1) Box Collision, 빠른 틱 주기로 반복되는 공격에서, 타겟 간 틈이 생기지 않게 하려는 목적(스웝형으로 감싸듯 맞게)

 

FX는 GameUtil이펙트 통합처리

- 각 스킬마다 Attach / Location형 이펙트 호출이 섞여 복잡해지는 걸 방지하려 했다. 그래서 GameUtil에 "Fx생성용 통합 함수"를 만들었다. 

void FPC_GameUtil::SpawnEffectAtLocation(UObject* WorldContextObj, UNiagaraSystem* NiagaraSystem, FVector Location,
                                         FRotator Rotation, float Scale)
{
	UNiagaraFunctionLibrary::SpawnSystemAtLocation(WorldContextObj, NiagaraSystem, Location,
		Rotation, FVector(Scale));
}

void FPC_GameUtil::SpawnEffectAtLocation(UObject* WorldContextObj, UParticleSystem* ParticleSystem, FVector Location,
                                         FRotator Rotation, float Scale)
{
	UGameplayStatics::SpawnEmitterAtLocation(WorldContextObj, ParticleSystem, Location, Rotation);
}

UNiagaraComponent* FPC_GameUtil::SpawnEffectAttached(UNiagaraSystem* NiagaraSystem, USceneComponent* AttachToComponent,
                                                     ::FName AttachPointName, FVector Location, FRotator Rotation,
                                                     EAttachLocation::Type LocationType, bool bAutoDestroy)
{
	return UNiagaraFunctionLibrary::SpawnSystemAttached(NiagaraSystem, AttachToComponent, AttachPointName, Location,
	                                                    Rotation, LocationType, bAutoDestroy);
}

UParticleSystemComponent* FPC_GameUtil::SpawnEffectAttached(UParticleSystem* ParticleSystem,
                                                            USceneComponent* AttachToComponent, FName AttachPointName,
                                                            FVector Location, FRotator Rotation,
                                                            EAttachLocation::Type LocationType, bool bAutoDestroy)
{
	return UGameplayStatics::SpawnEmitterAttached(ParticleSystem, AttachToComponent, AttachPointName, Location,
	                                              Rotation, LocationType, bAutoDestroy);
}

 

Attach형은 손본이나 무기에 붙기 때문에 추가적으로 관리되는 포인트가 있기 때문에 리턴값이 있다.

반면에 Location형은 단발형 이펙트이기 때문에 추가적으로 리턴값은 없다.


CrowdControl 시스템 설계

지속 피해에 반응하는 '상태 제어' 시스템, 예를 들어 DOT공격을 맞은 적이 일시적으로 Freeze 되는 식.

-구조핵심

1) CrowdControlComponent가 각각 캐릭터에 붙는다.

2) 내부엔 대표적으로 이런 함수들이 존재 RequestPlayCC(), CanPlayCC(),PlayCC(), StopCC()

3) 한 번에 하나의 CC만 적용됨 (중복 CC방지)

 

-시각 효과 처리

1) CC가 시작되면 오버레이 머테리얼이 교체되어 캐릭터 외형이 즉시 변경

2) 동시에 나이아 가라 FX를 몸 주위에 띄워 상태를 명확히 표시

 


데이터 확장성

이번 구현은 코드보다 데이터 기반 구조 확장에 초첨을 뒀다.

SkillTable : CC 연동 여부 데이터 컬럼 추가

CrowdControlTable : CC타입, 지속시간,FX/머테리얼 설정

CrowdControlTable

 

 


테스트 결과

지속 공격의 타격감 향상 : 짧은 주기로 반복 데미지가 들어가면서 전투 템포가 생김

 

 

 

 

 

 

 

왜 Curve?

선형(DeltaTime 기반)보간은 속도가 일정 -> 딱딱함

Curve는 "시간 -> 알파(0~1)를 매핑해 초반 가속/후반 감속 같은 자연스러운 모션을 쉽게 만들 수 있다.

대시, 카메라 줌, UI 페이드, 콤보 게이지, 보스 패턴 속도 변화에 유용


에디터에서 세팅 포인트

CurveFloat : 에셋 생성 -> 키 프레임 세팅

Interp Mode

Weighted Tangents : 구간별 미세하게 가속도 조정

등등..


적용사례

내 적용에는 근접 소드무기 스킬을 사용할때 애니메이션을 Curve로 사용했다.

 

세팅 가능한 설정으로 커브 그래프를 보간 할 수 있다.

 

커브 적용 전

 

커브 적용 후

 

 

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/keys-and-curves-in-unreal-engine

왜 넣었나

- 무기 타입(소드/스태프..)이나 현재 액션 상태(애임,가드 등)에 따라 같은 키 라도 다른 동작이 필요

- 기존 방식은 "상황이 늘어날 때 마다 분기 추가" -> 스파게티 + 유지보수 힘듬

- 목표 : (상황 -> 스킬슬롯) 매핑을 데이터화해서, 같은 키 입력으로 다른 동작을 하도록


구현 방법

- "현재 상황"을 최소 키로 요약 : 무기자세 + 스페셜 상태

- 그 조합을 Key로, 스킬 슬롯 묶음(Num1 ~ Num4)을 value로 갖는 TMap구성

- 입력이 들어오면 : FPC_Combkey 생성 -> TMap에서 스킬 조회 -> 해당 슬롯의 SkillId실행


데이터 CombKey 구조

USTRUCT(BlueprintType)
struct FPC_ComboKey
{
    GENERATED_BODY();

    UPROPERTY(EditAnywhere)
    EPC_CharacterStanceType CharacterStanceType = EPC_CharacterStanceType::Sword;

    UPROPERTY(EditAnywhere)
    bool bSpecialAction = false; // 가드/조준 등 “특수상태” 토글

    FPC_ComboKey() {}
    FPC_ComboKey(EPC_CharacterStanceType InStance, bool bInSpecial)
        : CharacterStanceType(InStance), bSpecialAction(bInSpecial) {}

    bool operator==(const FPC_ComboKey& Rhs) const
    {
        return CharacterStanceType == Rhs.CharacterStanceType
            && bSpecialAction == Rhs.bSpecialAction;
    }
};

 

 

DA_PlayerData 데이터 구성

- DA_PlayerData에 Skill Slot Datas 추가하고, 배열로 관리

- 각 원소는 Key(FPCCombKey) + Data(FPC_SkillSlots)

- 에디터에서는 각 상황(Key)에 대해 Num1~Numb4에 스킬 ID만 바꿔주면 새로운 무기/상태가 늘어나도 데이터 추가로 해결된다.

-Character Stance Type : 장착 무기 타입

-Special Action : 각각 무기에 따른 액션 (소드는 가드, 스태프는 애임 등등) 


사용 예시

void APC_PlayableCharaceter::Num1(const FInputActionValue& Value)
{
    const bool IsPressed = Value[0] != 0.f;
    if (!IsPressed)
       return;
    
    check(SkillComponent);
    check(BattleComponent);
    check(ActionComponent);
    
    const uint32 SkillId = FPC_GameUtil::GetSkillId(PlayerData,
       EPC_SkillSlotType::Num_1,
       BattleComponent->CharacterStanceType,
       ActionComponent->IsInSpecialAction);
    
    SkillComponent->RequestPlaySkill(SkillId);
}

Num1 키를 눌렀다면

uint32 FPC_GameUtil::GetSkillId(UPC_PlayerDataAsset* PlayerDataAsset, EPC_SkillSlotType SkillSlotType,
                                EPC_CharacterStanceType StanceType, bool bInSpecialAttack)
{
    TArray<FPC_SkillEntry>& SkillIdEntries = PlayerDataAsset->SkillSlotDatas;

    for (const FPC_SkillEntry& SkillEntry : SkillIdEntries)
    {
       if (SkillEntry.Key == FPC_ComboKey(StanceType, bInSpecialAttack))
       {
          return *SkillEntry.Data.SkillIds.Find(SkillSlotType);
       }
    }

    return 0;
}

StanceType과 SpeicalAttack 여부를 통해 해당 데이터를 반환 하도록 한다.

컨셉

- 데이터로 컨트롤 가능한 Exec 시스템 : Exec들을 순서대로 실행 -> 타겟을 바꿔가며 연속 대시/타격

- Curve로 이동 연출 : 순수 DeltaTime선형 이동은 밋밋 -> UCurveFloat알파로 감가속을 주어 "쫀득한"체감

- 최종 도착점 : 타겟 뒤쪽으로 스냅(조로 느낌). 경사/ 턱에서도 떠보이지 않게 지면 스냅 처리


데이터 구성

Exec Datas

타겟을 향해 달려가는 대시 공격Exec를 3개 넣었다.


동작 방식

타겟 선택 로직

TArray<TWeakObjectPtr<AActor>>& Targets = SkillInfo.Targets;
if (Targets.Num() == 0) return;

const uint32 TargetIndex = ExecInfo.ExecSequence % Targets.Num();
TWeakObjectPtr<ACharacter> Target = Cast<ACharacter>(Targets[TargetIndex]);
if (!Target.IsValid()) return;

- 포인트 : 체인이 길어도 ExecSequence % Num()로 자연스러운 타겟 순환

- 타겟이 죽거나 사라졌다면 다음 타겟으로 스킵


Curve 기반 대시 이동

float CurveAlpha = ExecInfo.ElapsedTime / Duration;
float PosAlpha = CurveAlpha;

if(ExecTableRow->ExeCurve)
    PosAlpha = ExecTableRow->ExeCurve->GetFloatValue(CurveAlpha);

- 속도는 = 거리/시간 대신에 CurveAlpha로 처리

- Curve는 에셋으로 조정 가능하도록 -> 연출/밸런싱 쉽게


타격 판정 : Overlap기반

대시 중 "히트 트리거"만 필요하다고 판단되어, Overlap사용 /데칼,임팩트 불필요

TArray<FOverlapResult> OverlapResults;
UWorld* World = GetWorld();
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(OwnerCharacter.Get());

if (World->OverlapMultiByProfile(OverlapResults, Pos, Rot.Quaternion(), TEXT("EnemyPreset"),
                                 CollisionShape, QueryParams))

지형 이슈(언덕에서 떠보임)

문제

Exec로 순간이동/ 대시하면 언덕,턱에서 최종위치가 공중에 살짝 뜸

충돌로 막히거나 NavMesh밖이면 박히거나 낙사 포인트

해결

1. 캡슐 스웝으로 위->아래를 훑어 실제 지면 접점 찾기

2. 찾은 지점을 NavMesh로 재투영(ProjectPointToNavigation)해서 길 찾기 가능한 위치 인지 확인

3. 최종 위치는 캡슐 반높이만큼 위로 올려 바닥에 파고들지 않게 보정

 


구현결과

 

마무리 체크리스트

  • Exec 시퀀스 → 타겟 순환 선택
  • Curve 알파로 이동 보간(속도 제어 X, 위치 보간 O)
  • 경사 지형 스냅(라인트레이스/스윕)
  • Overlap 판정(콜라이더 타입/사이즈 데이터화)

구현 결과/영상 포인트

  • 선형 대시 대비 초반 가속–후반 감속이 살아나 타격 몰입감 상승
  • 타겟 뒤 도착 각도/거리 데이터화 → 보스/잡몹에 따라 간단히 밸런싱 변경
  • 경사에서도 발 떠짐 최소화(지면 스냅)
  • Exec 쪼개기만으로 “2명→3명→N명” 체인 확장 가능

대시백 : 멍때리는 몬스터를 없애자

플레이어에게 과하게 붙어 "멍 때리는"몬스터를 없애고, 필요할 때 뒤로 빠지며 간격을 리셋하는 행동을 추가했다. 전투가 훨씬 긴박해지고 리듬감이 생김


왜 넣었나?

- 몬스터가 느린 움직임 + 짧은 공격 사거리일 때, 플레이어와 붙어 있음에도 휘두르지 못하는 애매한 구간이 자주 발생

- 이 구간이 길어지면 전투가 지루해지고, 몬스터가 바보처럼 보임

- 해결책: 조건을 만족하면 '빠르게 거리 확보' -> 다음 패턴으로 연결


설계 요약(방법)

- BT Decorator : PC_BTDecorator_CheckRange

-> 플레이어와의 거리 체크하는 데코 추가

- BT Task : PC_BTTask_DashBack

-> 1) 캐릭터를 플레이어 방향으로 회전시킨 뒤, 뒤로 빠지는 몽타주 실행

2) 몽타주 종료 델리게이트 Task 종료 제어

- 데이터 테이블화

-> 몬스터마다 대시백 애니메이션이 다름 : EnemyTable행 추가

- BT 세팅

-> CheckRange Deco, Dash Back Task 삽입


주요 구현

1) BT Task내 몽타주 종료 바인딩

// BT Task
FAICharacterMoveMontageFinished CharacterMoveMontage;
CharacterMoveMontage.BindLambda([&]
{
    FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
});

- 몽타주가 끝나는 타이밍을 정확히 받는 델리게이트를 걸고, 여기서 LatenTask종료 (이벤트 드리븐)

 

2) Cooldown 추가 계속해서 반복하지 않도록

3) ForceSuccess : 해당노드 무조건 true처리 -> 실패해도 이후 시퀀스 노드가 동작할 수있게

 


구현결과

 

다음에는 대시백 직후 플레이어와 거리를 바로 좁히는 동작을 구현할 예정이다.

에임 오프셋

내 프로젝트에서는 카메라와 캐릭터 시선이 항상 같지 않다. 캐릭터는 이동, 카메라는 자유 시점.

이때 "카메라가 보는 방향으로 상체만 조준"이 돼야 활/지팡이 원거리 조준감이 산다. 그래서 컨트롤 로테이션과 엑터 로테이션의 차이를 애니메이션으로 반영하는 "Aim Offset"이 필요하다.


구현에 필요한 핵심 개념

- ControlRotation : 플레이어 입력/ 카메라 기준의 회전(컨트롤러가 본 방향)

- ActorRotation : 실제 캐릭터 메시에 적용된 월드 회전

- NormalizeDeltaRotator(A,B) : A-B의 차이를 (-180, 180)범위로 정규화한 회전 -> 카메라-캐릭터의 각도 차를 얻기 위함

- FInterTo : 프레임마다 부드럽게 타겟으로 수렴(뚝뚝 끊키는거 방지)


구현부

void UPC_AimComponent::CalcAimOffset(float DeltaTime)
{
    check(OwnerCharacter.Get());

    FRotator ControlRotation = OwnerCharacter->GetControlRotation();
    FRotator ActorRotation = OwnerCharacter->GetActorRotation();

    FRotator NormalizedDeltaRotation = UKismetMathLibrary::NormalizedDeltaRotator(ControlRotation, ActorRotation);

    float NewPitch = FMath::FInterpTo(AimOffsetRotation.Pitch, NormalizedDeltaRotation.Pitch, DeltaTime, 30.f);
    float NewYaw = FMath::FInterpTo(AimOffsetRotation.Yaw, NormalizedDeltaRotation.Yaw, DeltaTime, 30.f);

    AimOffsetRotation = FRotator(NewPitch, NewYaw, 0.f);
}

- NormalizedDeltaRoator 쓰는 이유: 350도와 10도의 차이는 340이 아니라 -20여야 상식적인 회전으로 간다. 이 함수가 그걸 보장


애니메이션 연동

- Layered Blend per Bone : UpperBody부터 조준 애니 적용, 하체는 이동 애님 유지

- AimOffset Asset : Pitch/Yaw파라미터로 조준 포즈 보간


가드 -> 스페셜 통합(무기별 행동 스위치)

- 소드 : SpecialAction => Guard

- 스태프 : SpecialAction => Aim


조준 카메라 전환 (CameraType 데이터화)

- CameraType(Enum) : Normal, Aim

- CameraData(DataAsset) : TargetArmLength, SocketOffset, Fov, Rot

- 상태 전환 시 카메라 파라미터를 RInterep/Lerp로 자연스럽게 스위칭

const FVector TargetOffset = FPC_GameUtil::GetCameraData(CurrentCameraType)->SocketOffset;
const FRotator TargetArmRotation = FPC_GameUtil::GetCameraData(CurrentCameraType)->CameraRot;
const float TargetArmLength = FPC_GameUtil::GetCameraData(CurrentCameraType)->TargetArmLength;
const float TargetFOV = FPC_GameUtil::GetCameraData(CurrentCameraType)->CameraFov;

// 보간 처리
const FVector NewOffset = FMath::VInterpTo(SpringArmComponent->SocketOffset, TargetOffset, DeltaTime, 30.f);
const FRotator NewRot = FMath::RInterpTo(CameraComponent->GetRelativeRotation(), TargetArmRotation, DeltaTime, 30.f);
const float NewLen = FMath::FInterpTo(SpringArmComponent->TargetArmLength, TargetArmLength, DeltaTime, 30.f);
const float NewFOV = FMath::FInterpTo(CameraComponent->FieldOfView, TargetFOV, DeltaTime, 30.f);

 애임오프셋 구현 결과


원거리 공격 설계

- 콜리전 2중 구조

1) 대상 상호작용 콜리전 = 큼 : 투사체가 타겟을 살짝 빗나가도 맞게 허용

2) 환경 상호작용 콜리전 = 작음 : 벽 모서리/틈 사이에서 불필요한 충돌 방지, 매끄럽게 통과

- ProjectileMovementComponent : 속도, 중력 스케일, 바운스, 호밍 등 투사체를 쉽게 데이터로 제어 가능

- SkillObject 클래스 추가 : BeginOverlap,EndOverlap, DespanSound 등등 기본적인 구현

- 스폰 위치

FVector Location = SkeletalMeshComponent->GetSocketLocation(TEXT("hand_l"));
FRotator Rotation = PlayerController->GetControlRotation();


원거리 공격 구현 결과

 


이번에 막힌 지점

- 문제 : 카메라를 350도 -> 10도로 넘길 때 상체가 반대쪽으로 빙빙돔 => NormalizedDeltaRotator 로 정규화처리

- 벽 모서리에 투사체가 자꾸 걸림 => 환경 충돌 콜리전을 작게, 대상용은 크게 분리. 각각 콜리전 채널 다르게 처리

 

무기 장착 & 어택 트레이스

왜 넣었나

1. 무기 장착 시스템 : 데이터 기반 무기 장착 로직, 캐릭터가 들 수 있는 무기를 테이블 데이터로 관리하고, 이 데이터를 이용해 캐릭터의 손 소켓에 자동으로 무기를 Attach하는 과정이 필요

즉, 무기ID -> 데이터 조회 -> 소켓 위치에 장착 -> 오프셋 보정

2. 어택트레이스 시스템 : 지금까지의 공격은 단순히 Notify한 지점에서 콜리전 체크만 하는 수준이었다.

하지만 실제 액션 게임에서는 공격 모션 전체를 따라가는 궤적 판정이 필요하다.


1. 무기 장착 시스템

무기를 손에 들기 위해서는 단순히 StaticMesh만 세팅하는 게 아니라,

캐릭터의 손 소켓(Weapon Socket)에 Attach해야한다.

 

1. 무기 데이터는 WeaponTable로 관리한다.

WeaponId 무기 고유 ID
WeaponMesh 무기 StaticMesh
Damage 공격력 수치
TraceStartSocketName 트레이스 시작 소켓
TraceEndSocketName 트레이스 종료 소켓
RelativePos 소켓 기준 위치 오프셋
RelativeRot 소켓 기준 회전 오프셋

 

EquipWeapon() 구현 흐름

void UPC_BattleComponent::EquipWeapon(uint8 InWeaponId, bool bRightHand)
{
    // 1. 테이블에서 무기 정보 로드
    Weapon_R_TableRow = FPC_GameUtil::GetWeaponData(InWeaponId);
    
    // 2. 캐릭터의 손 소켓에 Attach
    UStaticMeshComponent* WeaponStaticMeshComponent = Interface->GetWeapon_R_StaticMeshComponent();
    WeaponStaticMeshComponent->DetachFromComponent(FDetachmentTransformRules::KeepRelativeTransform);
    WeaponStaticMeshComponent->AttachToComponent(SkeletalMeshComponent, FAttachmentTransformRules::KeepRelativeTransform, WeaponSocketName);

    // 3. 오프셋, 회전, 메시 세팅
    WeaponStaticMeshComponent->SetRelativeLocation(WeaponTableRow->RelativePos);
    WeaponStaticMeshComponent->SetRelativeRotation(WeaponTableRow->RelativeRot);
    WeaponStaticMeshComponent->SetStaticMesh(WeaponTableRow->WeaponMesh);
}

2. 어택 트레이스 (Attack Trace)

기존 문제

- 애니메이션 Notify시점 1프레임만 충돌 체크 -> 휘두르는 도중 판정 누락

- 타격 타이밍 시각적으로 맞지 않음

해결 방식

- AttackNotify에 "Start /End 체크 플래그 , BoneName, 오른(왼)손"추가 

Trace 구조

더보기

Trace 전체 코드

// Trace 할 라인들 모음
	TArray<TPair<FVector, FVector>> TraceLines;
	TraceLines.Emplace(PrevStartBoneLocation, CurStartBoneLocation);  
	TraceLines.Emplace(PrevEndBoneLocation, CurEndBoneLocation);
	TraceLines.Emplace(PrevStartBoneLocation, CurEndBoneLocation);    
	TraceLines.Emplace(PrevEndBoneLocation, CurStartBoneLocation);    
	TraceLines.Emplace(CurStartBoneLocation, CurEndBoneLocation);

	int32 SegmentCount = 3;
	
	// 💡 추가: 분할 점 기반 연결선
	for (int32 i = 1; i < SegmentCount; ++i)
	{
		const float Alpha = static_cast<float>(i) / SegmentCount;

		const FVector PrevMid = FMath::Lerp(PrevStartBoneLocation, PrevEndBoneLocation, Alpha);
		const FVector CurrMid = FMath::Lerp(CurStartBoneLocation, CurEndBoneLocation, Alpha);

		TraceLines.Emplace(PrevMid, CurrMid);
	}
	
	//TODO 공격별로 히트 효과 여부 처리
	bool HitAction = false;
	if(ActionComponent)
		HitAction = ActionComponent->IsLastAttack();
	
	FCollisionQueryParams Params;
	Params.AddIgnoredActor(GetOwner());

	for (const auto& Line : TraceLines)
	{
		FHitResult HitResult;
		ECollisionChannel CollisionChannel = FPC_GameUtil::GetAttackCollisionChannel(Character->CharacterDataID);
		
		if (World->LineTraceSingleByChannel(HitResult, Line.Key, Line.Value, CollisionChannel, Params))
		{
			AActor* HitActor = HitResult.GetActor();

			if (HitActor && !DamagedActor.Contains(HitActor))
			{
				DamagedActor.Add(HitActor);

				if (APC_BaseCharacter* HitCharacter = Cast<APC_BaseCharacter>(HitActor))
				{
					
					bool IsGuard = false;
					bool IsRolling = false;

					if(IPC_PlayerCharacterInterface* HitPlayerCharacterInterface = Cast<IPC_PlayerCharacterInterface>(HitActor))
					{
						 if(UPC_ActionComponent* HitCharActionComp = HitPlayerCharacterInterface->GetActionComponent())
						 {
						 	IsGuard = HitCharActionComp->IsGuarded();
						 	IsRolling = HitCharActionComp->IsRolling;
						 }
					}
					
					if (IsRolling)
						continue;
					
					if(HitAction)
						FPC_GameUtil::PlayHitStop(this, 0.2f, 0.f);
					
					if(IsGuard)
					{
						HitCharacter->LaunchCharacter(HitCharacter->GetActorLocation(), HitResult.ImpactPoint, 20);
						
						if (UPC_CharacterDataAsset* HitCharDataAsset = HitCharacter->GetCharacterDataAsset())
						{
							SpawnEffect(HitResult.ImpactPoint, HitCharDataAsset->GuardFx);
						}
					}
					else
					{
						UE_LOG(LogPC, Log, TEXT("Hit!!"));
						FPC_GameUtil::CameraShake(EPC_CameraShakeMagnitudeType::Weak);
						const float Damage = Character->StatComponent->GetTotalStat().Attack;
						FDamageEvent DamageEvent;
						HitActor->TakeDamage(Damage, DamageEvent, Character->GetController(), Character);

						if (UPC_CharacterDataAsset* HitCharDataAsset = HitCharacter->GetCharacterDataAsset())
						{
							SpawnEffect(HitResult.ImpactPoint, HitCharDataAsset->HitFx);
						}
					}
				}
			}
		}

		if(FPC_GameUtil::IsDebugDrawing(OwnerCharacter.Get()))
		{
			DrawDebugLine(World, Line.Key, Line.Value, FColor::Red, false, 3.f, 0, 1.f);
		}
	}

	// Prev 갱신
	PrevStartBoneLocation = CurStartBoneLocation;
	PrevEndBoneLocation = CurEndBoneLocation;

 

 

// 💡 무기 궤적을 따라가며 체크
TArray<TPair<FVector, FVector>> TraceLines;
TraceLines.Emplace(PrevStartBoneLocation, CurStartBoneLocation);
TraceLines.Emplace(PrevEndBoneLocation, CurEndBoneLocation);
TraceLines.Emplace(PrevStartBoneLocation, CurEndBoneLocation);
TraceLines.Emplace(PrevEndBoneLocation, CurStartBoneLocation);
TraceLines.Emplace(CurStartBoneLocation, CurEndBoneLocation);

 

여기에 더해서, 궤적이 빠를수록 빈 공간이 생길 수 있기 때문에

중간 분할  점 기반 보간으로 라인을 추가한다.

int32 SegmentCount = 3;
for (int32 i = 1; i < SegmentCount; ++i)
{
    float Alpha = (float)i / SegmentCount;
    FVector PrevMid = FMath::Lerp(PrevStartBoneLocation, PrevEndBoneLocation, Alpha);
    FVector CurrMid = FMath::Lerp(CurStartBoneLocation, CurEndBoneLocation, Alpha);
    TraceLines.Emplace(PrevMid, CurrMid);
}

 

LineTraceSingleByChannel

언리얼의 기본 라인 트레이스 함수로, 월드 내 두 점 사이의 충돌체를 감지하는 하도록 했다.

World->LineTraceSingleByChannel(HitResult, Line.Key, Line.Value, CollisionChannel, Params);

 

DrawDebugLine : 트레이스가 정상적으로 궤적을 따라가고 있는지 시각적으로 확인

DrawDebugLine(World, Line.Key, Line.Value, FColor::Red, false, 3.f, 0, 1.f);

 


3. 무기 소켓과 본 관리

무기 장착 시에는 WeaponSocket을 사용하지만, 공격 트레이스 시점에는 "무기 본"이 존재할 수도, 아닐 수도 있다.

ex) 맨손으로 때리는 캐릭터

 

그래서 로직은 다음과 같이 분기

- 무기 장착중 -> Weapon Bone 이름 사용

- 맨손 공격 -> AttackNotfiy의 BoneName 사용

상황에 따라 Trace기준이 되는 본을 선택할 수 있도록 설계


4. 결과

플레이어
적 Enemy


5. 이번 구현에서 배운점

단순 콜리전보다는 트레이스 기반 판정은 정교하다.

AttachToComponent /Socket시스템을 이해해야 애니메이션과 무기가 정확히 일치한다.

디버그 라인을 통한 시각화 중요하다. 

목표 & 고민 포인트

소울라이크 기본 무브셋인 가드구르기를 추가했다.


목표

가드

- 이동 중(달리기)엔 가드가 어색 -> IsRunning아닐 때만 허용

- 현재 포즈에서 가드 포즈로 자연스럽게 블렌딩

- 상체만 드는 가드 vs 전신 전환? -> 상체 레이어 오버레이로 가볍게

구르기

- 입력 방향으로 즉시 캐릭터 회전 + 루트 모션으로 이동

- 구르기 중에는 공격/점프/가드/이동 액션 못하도록 (Action Lock 시스템)


가드

인풋 & 조건

- 입력 : 우클릭

- 조건 IsRunning == false, (다른 액션중에는 X)

- 애니메이션추가

 

- 블랜드 Layered blend per bone

 

1) Guard 전환 부드럽게 하기 SpecialAction(= Guard) 

- 툭툭 끊키지 않게, 블랜드

- FInterTo : 이전 값 기반으로 감속 보간 처리

 

위에서 가져온 Value로 기반 Normal포즈와 Guard 포즈를 블랜드 처리한다.

 


구르기

핵심 : 루트모션 몽타주

- 이동은 애니메이션이 끌고가고, 캐릭터는 입력 방향으로 즉시 회전처리

- 다른 액션은 락으로 차단(구르는 일때)

-  구현 코드

void UPC_ActionComponent::Roll(bool bPressed)
{
    if (bPressed && !IsRolling)
    {
       if (!CanAction(EPC_ActionType::Roll))
          return;
       
       if(!TryConsumeStaminaOnActionStart(EPC_ActionType::Roll))
          return;
       
       const APlayerController* PlayerController = CastChecked<APlayerController>(OwnerCharacter->GetController());
       const IPC_PlayerCharacterInterface* Interface = CastChecked<IPC_PlayerCharacterInterface>(GetOwner());
       UPC_BattleComponent* BattleComponent = Interface->GetBattleComponent();
       check(BattleComponent);

       UPC_PlayerDataAsset* PlayerData = Interface->GetPlayerData();
       check(PlayerData);
       
       AddLock(EPC_LockCauseType::Roll, EPC_ActionType::Move);
       AddLock(EPC_LockCauseType::Roll, EPC_ActionType::Attack);
       AddLock(EPC_LockCauseType::Roll, EPC_ActionType::Jump);
       AddLock(EPC_LockCauseType::Roll, EPC_ActionType::Guard);
       
       IsRolling = true;

       const FRotator Rotation = PlayerController->GetControlRotation();
       const FRotator YawRotation(0, Rotation.Yaw, 0);
       const FVector ForwardDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
       const FVector RightDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
       
       FVector RollDir = FVector::ZeroVector;
       RollDir += ForwardDirection * InputVector.Y;
       RollDir += RightDirection * InputVector.X;

       OwnerCharacter->SetActorRotation(RollDir.Rotation());
       
       UAnimInstance* AnimInstance = OwnerCharacter->GetMesh()->GetAnimInstance();
       check(AnimInstance);
       
       AnimInstance->StopAllMontages(0.1f);
       OwnerCharacter->PlayAnimMontage(PlayerData->RollMontage);
       FOnMontageEnded EndDelegate = FOnMontageEnded::CreateUObject(this, &ThisClass::OnMontageEnd);
       AnimInstance->Montage_SetEndDelegate(EndDelegate);
       
       BattleComponent->EndTrace();
    }
}

- 입력 벡터를 카메라 기준 전/우 벡터로 투영해서 RollDir 구함

- 추가 구현 요소 : 피격 회피, 구르기 먼지 fx

왜 만들었나?

레벨 작업을 하다 보면, 특정 엑터(예: 지형, 바위, 오브젝트 등) 위에 다른 엑터를 랜덤하게 배치해야 할 때가 많다.

하나하나 손으로 배치하는 건 시간이 오래 걸리고 반복 작업이 많다. 그래서 UBlueprintFunctionLibrary 을 이용해서 액터를 스폰하는 간단한 툴을 만드려고 한다.


어떻게 구현 했나?

1. 에디터에서 엑터 가져오기

- EditorActorSubsystem -> Get Selected Level Actor

(EditorActorSubsystem : 언리얼에서 에디터 전용 Subsystem중 하나)

2. 입력 수량 세팅

- UI(Text Box)에서 스폰할 개수를 입력받아 Spawn Count로 저장

- 버튼 클릭 시 이 수량만큼 랜덤 스폰을 발행

3. 랜덤 위치 계산

- 선택한 액터의 바운딩 박스를 구한다.

- 바운드의 Min/Max를 용해 랜덤 좌표 생성

4. 액터 스폰

 

Designer 탭


구현 결과

씬에서 엑터를 선택한 후, 스폰 개수를 입력하고 버튼을 누르면, 선택한 엑터 위에 랜덤하게 새로운 엑터들이 스폰된다.

 

 

왜 이 작업을 했나

한 종류 무기만 있을 때 전투 표현력이 제한되고, 데이터 추가만으로 무기타입을 늘릴 수 있게 하고 싶었다.


1. 입력 세팅 (Input)

- Input Action : IA 키 추가

- Input Mapping Context : 키 'Q'에 바인딩


2. Weapon Table 데이터 입력

Staff 무기 데이터 추가


3. 애니메이션 블랜드

- 소드->스태프, 스태프->소드 자연스럽게 교체 되도록

- 현재 장착된 애니메이션에 맞게 이동 되도록

 

 

이번 구현의 목적

기존에는 단순한 이동 입력만으로 캐릭터가 방향을 바꿨다.

하지만 실제 전투에서는 카메라 방향과 플레이어 입력 방향이 다를 수 있고, 락온 기능이 없으면 시야와 방향이 따로 놀아 조각감이 나빠진다. 그래서 이동과 타겟 락온 기능을 추가 예정이다.


1. 이동처리 - 벡터 내적으로 방향 계산

핵심 개념

- 전방벡터(Forward)와 우측벡터(Right)를 기준으로 현재 이동 입력(벨로시티)을 내적해 스칼라값으로 변환한다.

이렇게 계산된 스칼라 값으로 BlendSpace파라미터를 조정하면 캐릭터가 자연스럽게 방향 전환하며 이동한다.

벡터 내적은 두 벡터의 방향이 얼마나 가까운지를 측정하는 값이다.

값이 1에 가까울수록 같은 방향이고, -1에 가까울수록 반대 방향이다. 


2. 락온 기능

락온은 타겟을 고정하고, 캐릭터가 항상 타겟을 바라보도록 하는 기능이다.

1. 타겟 찾기 방법(Find Target)

타겟을 검출하는 방법으로 플레이어의 시야 각도 내에서 가장 가까운(각도가 좁은) 타겟을 찾는다.

APawn* UPC_LockOnComponent::FindTarget()
{
    UWorld* CurrentWorld = GetWorld();
    check(CurrentWorld);

    ACharacter* Owner = Cast<ACharacter>(GetOwner());
    check(Owner);

    APlayerController* PlayerController = Cast<APlayerController>(Owner->GetController());
    check(PlayerController);

    //플레이어가 바라보는 
    FVector CameraForward = PlayerController->GetControlRotation().Vector();
    CameraForward.Z = 0.f;

    FVector OwnerLocation = Owner->GetActorLocation();
    
    FCollisionQueryParams QueryParams;
    QueryParams.AddIgnoredActor(Owner);

    //반경 TargetDetectRadius 으로 Detection
    TArray<FOverlapResult> OverlapResult;
    CurrentWorld->OverlapMultiByChannel(OverlapResult, OwnerLocation, FQuat::Identity,
       ECC_GameTraceChannel3, FCollisionShape::MakeSphere(TargetDetectRadius),QueryParams);

    APawn* FoundTarget = nullptr;
    float BestAngle = INT_MAX;

    //사잇값이 가장 좁은 타겟 찾기
    for (FOverlapResult& Result : OverlapResult)
    {
       APawn* ResultPawn = Cast<APawn>(Result.GetActor());
       if (!ResultPawn)
          continue;
       
       IPC_CharacterInterface* CharacterInterface = Cast<IPC_CharacterInterface>(ResultPawn);
       check(ResultPawn);

       if (CharacterInterface->IsDead())
          continue;;
       
       FVector ToTargetDir = (Result.GetActor()->GetActorLocation() - OwnerLocation).GetSafeNormal();
       float OffsetAngle = FMath::RadiansToDegrees(FMath::Acos(ToTargetDir.Dot(CameraForward)));
       if (OffsetAngle < TargetDetectAngle)
       {
          if (OffsetAngle < BestAngle)
          {
             FoundTarget = ResultPawn;
             BestAngle = OffsetAngle;
          }
       }
    }
    
    return FoundTarget;
}

코드 설명 :

- OverlapMultiByChannel : Sphere충돌로 주변 타겟 검출

- 내적(ToTargetDir.Dot(CameraForward) : 타겟 방향과 카메라 방향의 코사인 값 Acos로 각도 변환

- ToTargetAngle : 시야 제한 각도. 이 안에서 가장 작은 각도 타겟 선택

 

고민 포인트

- 단순 거리 비교 대신 각도 비교로 전방성 확보

- 탐색 반경과 각도 제한을 변수로 조정 가능

- 사망 상태인 적은 제외

2.  락온 중 이동

- 락온 상태에서 캐릭터가 타겟을 바라본 채로 이동해야한다.

- 따라서, 타겟 기준으로 원형 이동을 구한다.

void UPC_ActionComponent::ProcessLockOnMove()
{
    const IPC_PlayerCharacterInterface* Interface = CastChecked<IPC_PlayerCharacterInterface>(GetOwner());
    const UPC_LockOnComponent* LockOnComponent = Interface->GetLockOnComponent();
    check(LockOnComponent);
    
    if (AActor* TargetActor = LockOnComponent->GetLockTarget())
    {
       const FVector MyLocation = OwnerCharacter->GetActorLocation();
       const FVector TargetLocation = TargetActor->GetActorLocation();

       const FVector ToTarget = (TargetLocation - MyLocation).GetSafeNormal();

       const FVector OrbitRight = FVector::CrossProduct(FVector::UpVector, ToTarget); 
       const FVector OrbitForward = ToTarget;                                        

       FVector MoveDir = OrbitRight * InputVector.X + OrbitForward * InputVector.Y;
       MoveDir.Normalize();

       OwnerCharacter->AddMovementInput(MoveDir);

       FRotator LookAtRot = (TargetLocation - MyLocation).Rotation();
       LookAtRot.Pitch = 0.f; 

       const FRotator CurrentRot = OwnerCharacter->GetActorRotation();
       const FRotator NewRot = FMath::RInterpTo(CurrentRot, LookAtRot, GetWorld()->GetDeltaSeconds(), 10.f);
             
       OwnerCharacter->SetActorRotation(NewRot);
    }
}

코드 설명 : 

- CrossProduct : UpVector와 ToTarget의 외적으로 오른쪽 방향 계산, 타겟 중심 로컬 축 생성.

- 입력 변환 : X(좌우), -> OrbitRight, Y(전후) -> OrbitForward. 결과 : 원형 이동

- 회전 : RInterpTo 로 델타 타임 기반 부드러운 LookAt처리

 

3. 락온 위젯추가

- 고민 요소: 1) 위젯을 타겟 캐릭터에 붙일지 (AttachToActor), 2) 매 프레임 위치 업데이트할지.

=> 1)으로 결정 추후 인터렉션에 다른 아이콘도 추가 예정이기 때문

 

위젯 Visibility: ESlateVisibility Enum 사용.

  • Visible: 보이고 히트 테스트 O.
  • Collapsed: 안 보이고 공간 차지 X.
  • Hidden: 안 보이지만 공간 차지 O.
  • HitTestInvisible: 보이지만 히트 테스트 X (클릭 무시).
  • SelfHitTestInvisible: 자신만 히트 테스트 X, 자식은 O.

- 엔진에서 디버깅 하는 방법 : 툴 -> 디버그 -> 위젯 리플렉터 -> 히트테스트 

 

4. 락온 Input Key 추가

 - 'Tab'키를 이용해 락온 모드 On/Off처리 하도록 했다. 

- 타겟이 있으면, LockOn, 없으면 해제, 위젯도 토글


마무리

이번 구현으로 전투 캐릭터의 '조작감'과 '락온'기능을 추가해 전투 몰입도를 끌어올릴 수 있었다.

 

 

 

 

 

 

왜 넣었나?

단순 시야 추적만으로는 연출 다양성이 부족함 : 시야 밖에서도 "소리 듣고 갸우뚱 -> 확인 하러감 ->발견 시 전투 전환" 같은 연출이 필요

전투 리액션 강화 : 피격(촉각/데미지)시 즉시 타겟 전환, 어그로 재설정 등,

유지보수/확장성 : 시각/청각/피해를 표준 이벤트(Simuls)로 통합 관리 -> BT/BB와 둥글게 연결


구성(설계 요점) 리스트

1. AIController에 PerceptionComponent 추가

2. SenseConfig 3 종 생성/등록

3. StimuliSource 부착&등록

4. 블랙보드/BT 키,상태 정의

5. 소리/피해 이벤트 발생 경로 연결

6. 테스트

구현부 주요 내용

몹 AIController에 정의

PerceptionComponent->SetDominantSense();

=> 동시에 들어왔을때 우선순위, 

PerceptionComponent->OnPerceptionUpdated.AddDynamic(this, &ThisClass::OnPerceptionUpdate);

=> 인지가 될때 업데이트 되는 함수(델리게이트로 바인딩)

SenseConfig

아래는 내 코드에서 SenseConfig를 이렇게 정의했다.

void APC_AIController::SetupSenseConfig()
{
	const FPC_EnemyTableRow* EnemyTableRow = GetEnemyData();
	ensure(EnemyTableRow);

	SightSense->SightRadius = EnemyTableRow->SightRadius;
	SightSense->LoseSightRadius = EnemyTableRow->LoseSightRadius;
	SightSense->PeripheralVisionAngleDegrees = EnemyTableRow->SightAngle;
	//얼마나 기억할건지
	SightSense->SetMaxAge(5.f);
	SightSense->DetectionByAffiliation.bDetectEnemies = true;

	HearingSense->HearingRange = 1500.f;
	HearingSense->DetectionByAffiliation.bDetectEnemies = true;
	HearingSense->SetMaxAge(5.f);

	DamageSense->SetMaxAge(2.f);

	//
	PerceptionComponent->ConfigureSense(*SightSense);
	PerceptionComponent->ConfigureSense(*HearingSense);
	PerceptionComponent->ConfigureSense(*DamageSense);
}

Sight (시각)

  • SightRadius / LoseSightRadius
    • SightRadius: 최초 발견 거리
    • LoseSightRadius: 시야를 잃는 거리
  • PeripheralVisionAngleDegrees
  • MaxAge(=SetMaxAge)
    • 마지막으로 본 자극을 얼마나 기억할지(초).
  • DetectionByAffiliation
    • bDetectEnemies = true만 두면, 팀 시스템(IGenericTeamAgentInterface) 기준 적군만 감지.

Hearing (청각)

  • HearingRange
    • 소리를 들을 수 있는 기본 범위. (예: 1500.f)
    • 실제 감지는 발생한 노이즈의 Loudness/MaxRange에도 영향.
    •  
  • MaxAge
    • “조금 전 들은 소리”를 얼마나 의심 상태로 유지할지.
    • 3~6초가 무난. 그 사이에 도착해서 확인 → 못 찾으면 Patrol 복귀.
  • 의사결정
    • 발견 아님 → Investigating 상태 전환 + InvestigatingPos에 위치 저장.
    • 갸우뚱/정찰 애님, 느린 Move, 도착 후 재탐색(EQS/시야 재확인) 추천.
    • MakeNoise
void APC_PlayableCharaceter::Jump(const FInputActionValue& Value)
{
	Super::Jump();

	MakeNoise(1, this, GetActorLocation());
	UGameplayStatics::SpawnSoundAtLocation(GetWorld(), PlayerData->JumpSound, GetActorLocation());
}

Damage (피해)

  • MaxAge
    • 피격 후 전투 모드 유지 시간 쪽 의미로 받아들여도 됨.
    • 너무 길면 “맞고 오래 집착”, 너무 짧으면 “금방 잊음”.
  • 정책
    • 보통 가장 강한 우선순위.
    • 즉시 Battle 전환 + 타겟 교체/어그로 갱신.
    • ReportDamgeEvent
float APC_NonPlayableCharacter::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent,
	AController* EventInstigator, AActor* DamageCauser)
{
	UAISense_Damage::ReportDamageEvent(
	GetWorld(), 
	this,
	DamageCauser,
	DamageAmount,
	DamageCauser->GetActorLocation(),
	GetActorLocation());
	
	return Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
}

 

타겟 UAIPerceptionStimuliSourceComponent 부착

StimulusSource = CreateDefaultSubobject<UAIPerceptionStimuliSourceComponent>(TEXT("Stimulus"));
StimulusSource->RegisterForSense(TSubclassOf<UAISense>(UAISense_Sight::StaticClass()));
StimulusSource->RegisterWithPerceptionSystem();

 


동작 방식

1. Perception 업데이트 발생 → UpdatedActors 전달

2. 죽은 대상/군중제어 상태 등 즉시 제외됨

3. 한 액터에 대해 Sight → Hearing → Damage 순으로 체크

4. 시야,데미지는 바로 전투 상태 전환(블랙보드 타겟 액터 저장), 청각은 갸우뚱 이동/애님(블랙보드에 위치저장)

 

추가로 감각을 스코어링해서 우선순위를 처리하도록 해야 할듯함


구현 결과

시각
청각
피해


마무리

UAIPerceptionComponent는 처음 보면 복잡해 보이지만, 한 번 구조를 잡아두면 표준화된 방식으로 처리 할 수 있다. 이번 포스팅에서 설명한 3요소만 적용해도 꽤 감각있는 몹AI처리가 된다.

스테미나 왜 넣었는가

 

액션을 무한 반복하면, 전투가 지루해지고, 밸런스가 없다.

스태미나로 의사 결정을 유도하면, 이동/회피/공격 사이 트레이드 오프가 생긴다.

밸런싱 포인트로 난이도 조정가능하다.


데이터 구조 설계

- ActionStaminaData : 스테미나 데이터

USTRUCT(BlueprintType)
struct FPC_ActionStaminaData
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    EPC_ActionType ActionType = EPC_ActionType::None;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(ClampMin ="0.0"))
    float StartCost = 0.0f;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(ClampMin ="0.0"))
    float MaintainCostPerSec = 0.0f;
};

- PlayerDataAsset

Tarry<ActionStaminaData> : 스테미나 데이터들

RegenPerSec : 초당 회복량

RefenDelaySec : 최근 소비 후 회복이 시작되기까지의 지연

 

 

- StatComponent 

CurStamina, MaxStatmina 를 통해 현재 상태에 스태미나정보를 갱신하거나 최신화해준다.


실제 적용 포인트

- 달리기

1) 달리기를 유지하면, 초당 MaintaninCostPerSec만큼 감소

2) 스태미나 0도달 시 자동 종료 ->, Walk로 변환

 

- 구르기

1) 시도 시 스태미나 사용여부 체크

void UPC_ActionComponent::Roll(bool bPressed)
{
    if (bPressed && !IsRolling)
    {
       if (!CanAction(EPC_ActionType::Roll))
          return;
       
       if(!TryConsumeStaminaOnActionStart(EPC_ActionType::Roll))
          return;
 ....         
}

2) 스태미나 차감후 구르기 액션 발동

 

- 공격,점프

1) 마찬가지로 스태미나 사용여부 가능한지 체크 한 후 선차감 액션 실행


UI 적용

HPBar와 마찬 가지로, 델리게이트를 통해 현재 스태미나를 갱신해준다.


구현 결과

달리기
공격/구르기

 

데이터 설계 : 액션별 소비/유지 비용을 데이터로 분리

상태/자원 결합 최소화 : 액션 시스템과 스탯 시스템을 느슨하게 연결(인터페이스와 델리게이트)

 

추가 구현요소

- 감소 게이지 효과

- 스태미나로 인한 액션 사용 불가 -> 토스트 메세지

하늘에서 쏟아지는 파이어볼

이번 화 목표

1. 타겟 낙하형 : 타겟 머리 위로 연속 투사체 낙하

2. 원형 낙하형 : 시전자 중심 각도 분할로 링 형태 낙하

3. BT테스크, 애님 몽타주, 데칼, 콜리전 작업


왜 이렇게 만들었나?

보스 공격 패턴을 주고 싶었다.

- 타겟 낙하형 "데칼 예고 -> 플레이어가 롤/움직임으로 피하는 구도

- 원형 낙하형 "각도 분할 + 반지름 조절"로 안전지대 읽기를 유도


데이터 설계

- SkillTable : 타겟 낙하형 (Target Player), 원형 낙하형(Non Target), 데칼 사용 여부

- ExecTable : 

 

  • 타겟 낙하형(FireMultipleProjectile)
    • ExecProperty_0: SkillObjectId
    • ExecProperty_1: LoopCount(= SpawnCount)
    • ExecCollisionProperty_0: RandomRadius(스폰 위치 랜덤 오차)
    • ExecCollisionProperty_1: SpawnHeight(머리 위 오프셋)
  • 원형 낙하형(FireCircularRain)
    • ExecProperty_0: SkillObjectId
    • ExecProperty_1: Radius(기본 반경)
    • ExecProperty_2: SpawnCount(분할 개수)
    • ExecCollisionProperty_0: SpawnHeight
    • ExecCollisionProperty_1: StartAngleDeg(첫 각도)

 


BT, 애님, 콜리전 프리셋

BT 테스크

-> Task추가해서 스킬 사용 하도록

 

애님 세팅

-> 시전 시간을 딜레이를 주도록 했다.

 

콜리전 프리셋

-> 투사체 채널을 별도로 분리


데칼 : 떨어지는 오브젝트

떨어지는 위치를 예상해서 플레이어가 피하도록 할 수 있게

MT를 활용해 만듬


스킬 오브젝트 BP (파이어볼)

데칼에 사용하 귀한 MT를 넣을 수 있게 처리, 오브젝트 디스폰될때 터지는 FX도 추가


구현결과

1. 타겟 낙하형

 

2. 원형 낙하형

보스 중심으로 점점 원형이 커지는것을 볼 수 있다. 데이터로 시전 횟수, 파이얼 스폰 횟수 조정 가능하다.


마무리

이번 화는 “낙하 패턴의 뼈대”를 만든 느낌.
다음은 데칼 머티리얼 최종 연출(펄스/회전/잔상)과 풀링 적용,
그리고 안전지대/회전 방향 전환으로 패턴을 더 풍성하게 가져갈 예정.

구현 기준

  • 타겟 낙하형: ProcessTargetPlayerExec(...) + ExecType: FireMultipleProjectile
  • 원형 낙하형: ProcessNonTargetExec(...) + ExecType: FireCircularRain

1. 왜 템플릿을 사용했는가?

다양한 캐릭터가 존재하는 게임에는 공통된 애니메이션을 동작하기 위한 작업을 매번 구현해 줘야하는데, 예를들어 기본적인 상태에 따른 동작, 이동, 죽는 애니메이션 등등 말이다.

- 애니메이션 중복제거

- 커스터마이즈 분리

코드에서 자주쓰는 상속개념을 ABP에서도 쓸수 있다. 


2. ABP 계층 구조 설명

 

- ABP_Base (Template) : 공통 로직, 공통 캐시 포즈, Blend Pose by Int 처리

- ABP_Locomotion (하위 템플릿) : StateMachine, BlendSpace 묶음(move, patrol, Investigating..) 

- ABP_몬스터별 구현체 : 개별 애니메이션 에셋 바인딩


3. 핵심 노드 설명

- Use Cached Pose

한 번 계산한 포즈를 캐싱해두고 여러 군데서 재사용

- Linked Anim Graph

Base <-> Loco연결, 모듈화

 

- Blend Poses by Int

Enum 기반 상태 전환


4. 주의 사항

- Notify 전파 : Base/Loco 구조로 Notify 가 안내려오거나, 순서가 꼬이는 경우

- Root Motion 정책 

- Slot 분리


5. Unity와 비교

Unity Animator Controller를 공통 레이어 + 하위 컨트롤러로 나눈 패턴과 유사?


6. 마무리

몬스터 종류가 많아질수록 필요한 구조이다. 지금은 ABP_{구현체} 에 딱히 크게 구현내용이 없지만, 이 ABP를 상속받아서 추가적인 상속구조를 만들 수 있을 것같다.

 

'Unreal > 애니메이션' 카테고리의 다른 글

[Unreal5] Animation Curve / 섹션 나누기  (0) 2025.04.09

박진감 넘치는 전투

왜 넣었나

기존 몬스터는 기본 공격 애니메이션 단 1개만 사용 -> 전투가 단조롭고 거리 조절도 불가능

목표는 연속 공격 체인과 거리 기반 공격 선택을 구현해, 전투에 박진감과 리얼리티를 추가하는 것


설계 고민

  1. 거리 기반 선택
    1. 몬스터가 가진 모션 중, 플레이어와의 현재 거리와 가장 잘 맞는 루트모션 이동거리를 가진 애니메이션 선택
    2. 예 가까우면 짧은 공격, 멀면 긴 거리 공격
  2. 툴 기반 데이터화
    1. 에디터 툴을 이용해 각 공격 애니메이션의 루트모션 이동 거리를 사전 계산
    2. 결과를 데이터 테이블에 저장해두고, 런타임에서는 거리와 가장 가까운 데이터를 빠르게 참조
  3. 연속공격 체인
    1. 마지막 공격을 제외하고 애니메이션 노티파이를 배치
    2. 해당 지점에서 다음 공격 모션을 곧바로 실행 -> 짧은 템포의 연속공격 연출

구현 과정

1. EnemyTable 테이블 구조 변경하기

- 단일 공격 -> 공격 애니메이션 배열로 확장

2. 루트모션 이동거리를 보관하는 테이블 생성

- 각 애니메이션에 루트모션 이동 거리 값을 함께 보관

- 에디터 툴 제작 및 테이블 채워넣기 (UBlueprintFunctionLibrary 사용)

테이블에 각 애니메이션의 루트모션 Distance가 채워진 모습

3. 공격 시 선택 로직

- 몬스터 위치와 플레이어 위치의 거리를 계산

- 데이터 테이블에 기록된 루트모션 거리와 비교해 가장 적합한 공격 애니메이션을 선택

4. 연속공격 노티파이 세팅

- 공격이 끝나기 직전 노티파이 발생

- 즉시 다음 공격 몽타주 실행 -> 부드럽게 이어지는 콤보

5. 테스트

- 디버그 시각화로 루트 모션 거리와 실제 거리 매칭 확인


동작 결과

- 노티파이 지점에서 빠르게 다음 공격이 이어짐

- 플레이어가 가까울때는 이동거리가 짧은 모션

 

- 플레이어가 멀리 있을때 이동거리가 긴 애니메이션 실행


동작 결과

이번 작업은 루트모션 거리 데이터 + 연속 공격체인을 활용한 공격 다양성과 박진감이있는 전투로 바꾼 사례다.

툴 기반으로 데이터를 관리 한점, 전투 템포를 체감적으로 개선한 점이 이번 구현의 핵심이었다.

1. 왜 이 방식이 필요한가? (왜 넣었나)

공통된 기능, 나의 경우에는 플레이어나 몬스터 캐릭터에 HP,MP..등 HUD를 스텟 정보가 많아지면 TextBlock을 일일이 업데이트하는건 무리가 있다.

점점 더 늘어 나면 지옥!


2. 언리얼 Reflection 시스템 개념

간단히 언리얼 엔진에서는 Reflection시스템이 있다. 간단히 말해, 코드에서 선언한 클래스/구조체/프로퍼티를 런타임에 탐색하고 다룰 수 있게 해주는 기능이다.

 

왜 필요할까?

나의 경우에는 스탯처럼 필드가 많고, 자주 바뀌는 데이터를 매번 수동으로 매핑하기엔 비효율적이었다.

코드를 수정하지 않고도 데이터 구조가 확장될 수 있게 된다. 

 

대표적 키워드

TFieldIterator : 특정 클래스나 구조체의 프로퍼티들을 순회할 수 있는 반복자

FProperty : 언리얼의 모든 프로퍼티 타입을 표현하는 기본 클래스

FNumericProperty : 숫자형 프로퍼티(int, float등)에 특화된 하위 클래스

FStrProperty → FString

FNameProperty → FName

FBoolProperty → bool

FObjectProperty → UObject 파생 클래스 참조

FStructProperty → UStruct (FVector, FRotator 등 포함)

FArrayProperty → TArray

FMapProperty → TMap

FSetProperty → TSet

 


3. 구현 코드 예시

void UPC_CharacterStatWidget::NativeConstruct()
{
	Super::NativeConstruct();

	for (TFieldIterator<FNumericProperty> PropIt(FPC_CharacterStatTableRow::StaticStruct()); PropIt; ++PropIt)
	{
		const FName PropKey(PropIt->GetName());
		const FName TextBaseControlName = *FString::Printf(TEXT("Txt%sBase"), *PropIt->GetName());
		const FName TextModifierControlName = *FString::Printf(TEXT("Txt%sModifier"), *PropIt->GetName());
		
		UTextBlock* BaseTextBlock = Cast<UTextBlock>(GetWidgetFromName(TextBaseControlName));
		if (BaseTextBlock)
		{
			BaseLookup.Add(PropKey, BaseTextBlock);
		}

		UTextBlock* ModifierTextBlock = Cast<UTextBlock>(GetWidgetFromName(TextModifierControlName));
		if (ModifierTextBlock)
		{
			ModifierLookup.Add(PropKey, ModifierTextBlock);
		}
	}
}
void UPC_CharacterStatWidget::UpdateStat(const FPC_CharacterStatTableRow& BaseStat, const FPC_CharacterStatTableRow& ModifierStat)
{
	for (TFieldIterator<FNumericProperty> PropIt(FPC_CharacterStatTableRow::StaticStruct()); PropIt; ++PropIt)
	{
		const FName PropKey(PropIt->GetName());

		float BaseData = 0.0f;
		PropIt->GetValue_InContainer(&BaseStat, &BaseData);
		float ModifierData = 0.0f;
		PropIt->GetValue_InContainer(&ModifierStat, &ModifierData);

		UTextBlock** BaseTextBlockPtr = BaseLookup.Find(PropKey);
		if (BaseTextBlockPtr)
		{
			(*BaseTextBlockPtr)->SetText(FText::FromString(FString::SanitizeFloat(BaseData)));
		}

		UTextBlock** ModifierTextBlockPtr = ModifierLookup.Find(PropKey);
		if (ModifierTextBlockPtr)
		{
			(*ModifierTextBlockPtr)->SetText(FText::FromString(FString::SanitizeFloat(ModifierData)));
		}
	}
}

4. 동작 방식 설명

이번에 구현한 함수는 구조체의 필드 -> UI(TextBlock)로 자동  바인딩하는 흐름으로 되어 있다.

for (TFieldIterator<FNumericProperty> PropIt(FPC_CharacterStatTableRow::StaticStruct()); PropIt; ++PropIt)

=> TFieldIterator를 사용해 FPC_CharacterStatTableRow 구조체 안의 모든 숫자형 프로퍼티를 순회, 이렇게 하면 스탯 필드가 늘어나더라도 코드를 바꾸지 않아도 된다. 

 

	float BaseData = 0.0f;
	PropIt->GetValue_InContainer(&BaseStat, &BaseData);
	float ModifierData = 0.0f;
	PropIt->GetValue_InContainer(&ModifierStat, &ModifierData);

=> Reflection으로 해당 프로퍼티의 값을 직접 꺼낸다.

 

UTextBlock** BaseTextBlockPtr = BaseLookup.Find(PropKey);
if (BaseTextBlockPtr)
{
    (*BaseTextBlockPtr)->SetText(FText::FromString(FString::SanitizeFloat(BaseData)));
}

 

=> PropKey 이름(프로퍼티 이름)을 기준으로 TextBlock을 찾아 매칭

 

스탯 Table
WBP

 


5.Unity와 비교

유니티 방식. 유니티에서도 Reflection이 있다. 다만 두 엔진의 구조가 조금 다르다. typeof.GetFilds() 이런식으로 구조체/클래스의 필드를 런타임에 열람가능, 주로 에디터 자둥화나 툴링에서 활용, C# Reflection자체가 언어 차원에서 지원

 

언리얼방식. 모든 프로퍼티를 UPROPERTY()로 마킹해두면 리플렉션 시스템에서 자동 관리, 엔진 차원에서 최적화 Reflection 제공, GamePlay에서도 자주 쓰임


6. 마무리

해당 리플렉션 기능으로 매번 수동으로 코드를 작성하지 않아도 되고, 구조체에 새로운 스탯을 추가해도 UI매핑이 자동으로 확장되도록 했다. 유지보수성과 확장성을 고려된 작업이라고 할 수있다.

 

UStruct 개요

C++ 구조체에 언리얼 리플렉션 시스템을 붙인 것.

UStruct는 UObject와 달리 가비지 컬렉션 대상이 아니고 함수도 가질 수 없음. 주로 데이터 묶음 용도, UObject보다 가볍고 빠르다.


사용 방법

USTRUCT(BlueprintType)
struct FPC_EnemyTableRow : public FTableRowBase
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Type)
	int32 EnemyType;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	USkeletalMesh* SkeletalMesh = nullptr;
}

1. USTRUCT() : 라는 매크로를 이용해 리플렉션 시스템에 등록

2. GENERATED_BODY(): 리플렉션 코드 자동 생성


구조체 지정자 

  • BlueprintType: 블루프린트 변수 타입으로 사용 가능
    • 구조체를 블루프린트에서도 변수 타입으로 쓸 수 있게 만듦
    • 블루 프린트 변수선언 및 노드 생성
  • Atomic: 항상 단일 단위로 직렬화
    • 구조체를 항상 단일 단위로 직렬화 (이건 언제 쓸지 잘모르겠다..)
  • NoExport: 자동 생성 코드 제외, 메타데이터 파싱 전용
    • 툴링/코드 파싱용으로만 쓰고, 블루프린트나 런타임에는 노출 안됨

UStruct 와 C++ struct 비교하기

UStruct 사용처

- 언리얼 리플렉션 시스템에 등록된 struct 

- UPROPERTY로 직렬화/저장/블루프린트 노출 가능

 

C++ struct

- 그냥 일반 값타입

- 언리얼 에디터나 블루프린트에서 인식 못함

- 직렬화, 리플렉션,GC 언리얼 기능 못씀

 

 

소울류 게임에서 자주보이는 암살 기능을 넣어보려고 한다. 

암살은 보통 상대 뒤에서 강한 데미지를 가하는 기능이다.

일정거리 이하이고 상대가 나를 인식하지 못했을때 특정 키를 이용해 기능이다.


 

소울류 암살(Backstab) 기능 구현 기록

왜 넣었나

소울류 게임에서 자주 보이는 암살 기능은 상대 뒤에서 강한 데미지를 가하는 것이다.
이 기능이 필요한 이유는 단순히 앞에서 맞붙는 평면적인 전투에 변화를 주고, “뒤를 잡아 한 방에 끝낸다”는 재미 요소를 추가하기 위함이다.


설계 고민

암살 가능 여부를 누가 판단할지 두 가지 접근을 생각했다.

  1. 몹 주도 체크
    • 몬스터가 스스로 상태, 시야, 청각, 플레이어와의 거리를 계산해 암살 가능 여부를 판단.
  2. 플레이어 주도 체크
    • 플레이어가 일정 주기로 주변 몹들을 스캔하고, 몹의 상태를 확인해 암살 가능한 대상을 선택.

처음에는 1번 방식을 고민했지만, 결국 암살 기능의 주체는 플레이어다.
따라서 플레이어가 주도적으로 체크하고, 몹은 자기 상태(전투 중, 패트롤 중, 추적 중 등)만 제공하도록 구조를 바꿨다.

 


동작 방식

  • 플레이어가 몬스터 뒤에서 일정 거리 이내에 접근하고, 몬스터가 플레이어를 인식하지 못했을 때 암살 아이콘을 노출한다.
    • 아이콘 위치는 목 위치로 (BoneName : "neck_01")
  • 이때 특정 키를 입력하면 암살 애니메이션이 발동
    • 몬스터 방향을 플레이어에게 등을 지도록 회전
      if (ACharacter* Victim = Cast<ACharacter>(BackstabTarget))
      	{
      		// 몬스터 → 플레이어 방향 벡터
      		FVector ToAttacker = (OwnerCharacter->GetActorLocation() - Victim->GetActorLocation()).GetSafeNormal2D();
      		// 그 방향을 바라보는 회전
      		FRotator VictimRot = ToAttacker.Rotation();
      		// Yaw에 180도 추가해서 등을 내주게 함
      		VictimRot.Yaw += 180.f;
      		Victim->SetActorRotation(VictimRot);
      	}
  • 몬스터 체력만큼의 데미지를 입힌다.
  • 단, 엘리트 몬스터나 보스는 암살이 불가능하거나, 한 번에 죽지 않는 예외 케이스를 고려한다.

구현 이슈

현재 몹은 상태에 따라 ABP(애님 블루프린트)에서 Dead 시퀀스를 재생한다.
문제는 암살로 적을 처치해도 일반 Dead 애니메이션이 실행된다는 점이었다.

이를 해결하기 위해 Dead Type을 추가했다.

  • Normal Dead, Backstab Dead 등으로 구분
  • 상황에 맞는 애니메이션을 재생하도록 수정

구현 결과


추가 구현 이슈

- 암살 애니메이션 플레이시 카메라 연출

- 암살하는 무기 파타클 스폰

- 애니메이션 속도 및 연출 추가

오늘은 몹 AI와 플레이어 간 전투가 조금 더 박진감 있게 느껴지도록 거리 좁히기 스킬을 추가했다.


왜 이기능이 필요한가?

전투를 하다면 몹과 플레이어에 거리가 벌어지는 경우가 생긴다. 이때 몹이 단순히 플레이어에게 걸어 오는것보다, 순간적으로 거리를 좁히는게 더 전투에 재미를 가져다 줄 수 있기 때문이다. 흔히 등장하는 대쉬 공격 같은 느낌이다.

 


Enemy AI Behavior Tree

아래는 현재 Enemy BT 이다.

 

Random Task 노드 활용 :  특정 확률로 타겟을 향해 걸어가거나, 특정 애니메이션과 스킬을 사용하면서 플레이어에게 빠르게 접근하도록 설정해 두었다.

 

 EQS 활용 : 로 플레이어 주위를 돌다가 플레이어와 거리가 멀어지면 스킬을 사용하도록.

 

이렇게 해서 단순히 "걸어오기 -> 공격"이 아니라, "거리 계산 -> 순간 접근 -> 다시 패턴 반복" 하는 흐름을 가지도록 만들었다.


플레이어 스텟 Widget 변경

원래는 Playable / NonPlayable을 나눠서 HUD를 따로 붙였는데, 관리가 번거로웠다.
그래서 BaseCharacter에서 공통 처리하도록 구조를 변경했다.
- HP / MP / Stamina를 통합
- 왼쪽 상단 HUD에 배치
덕분에 캐릭터 추가할 때 HUD를 따로 붙이지 않아도 된다.


보스 몬스터 추가

앞으로 다양한 스킬을 가진 보스 몬스터들을 추가할 계획이라 Data,BT,BP,몬스터 전용 스킬 등을 추가했다.
현재는 프로토타입 단계라 매쉬는 임시지만, 이후 어울리는 모델을 찾을 예정이다.


그 밖에.

아 그리고 이번에 이번에 언리얼엔진 버전 올렸다... 이유는 사용하려는 에셋이 5.4이후 버전이라..언리얼은 에셋이 업그레이드만 되지 다운그레이는 안된다는..


 

오늘의 핵심 변화:
몹 AI가 단순 추적만 하지 않고 “거리 좁히기 스킬” 사용
HUD 구조를 BaseCharacter 공통 처리로 개선
보스 몬스터 추가 및 확장 준비
프로젝트 버전 5.4로 업그레이드

+ Recent posts