반응형

UAnimNotifyState_TimedNiagaraEffect란?

언리얼에서 애니메이션 도중에 특정 구간 동안 이펙트를 재생하고 싶을 때 사용하는 클래스이다.

일반 AnimNotify는 한 프레임 순간만 호출되지만, AnimNotifyState는 시작~끝까지 유지되는 구조다.

PC_Attack~과 NS_SpeedTrail 다름

그중에서도 UAnimNotifyState_TimeNiagaraEffect는 Niagara이펙트를 사용해, 공격 잔상을 구현했다.


구현 방식

단순히 Niagara를 켜는 데서 그치지지 않고, 트레일(칼,잔상) 이펙트를 Start / End소켓 위치 기준으로 실시간 갱신하도록 확장했다. 


Niagara 파라미터 세팅

Niagara 시스템(NS_SpeedTrail)에 아래 파라미터를 만들어준다. (없다면)


TrailEffect.cpp 구현

구현내용

1. PC_TrailEffect.cpp 만들기

- bWeaponTrail : bool 변수 무기에 쓸건지

- BodyTrailBoneName_Start; : Trail시작

- BodyTrailBoneName_End; : Trail 끝

 

TPair<FName, FName> WeaponTraceNames = CharacterInterface->GetWeaponTraceNames(bRight);

FVector StartPos = StaticMeshComponent->GetSocketLocation(WeaponTraceNames.Key);
FVector EndPos = StaticMeshComponent->GetSocketLocation(WeaponTraceNames.Value);

if (UFXSystemComponent* FXSystemComponent = GetSpawnedEffect(MeshComp))
{
    FXSystemComponent->SetVectorParameter(TEXT("StartTrail"), StartPos);
    FXSystemComponent->SetVectorParameter(TEXT("EndTrail"), EndPos);
}

무기는 테이블에 입력되어있다.

결국 캐릭터가 자기 자신이 현재 들고있는 무기에 기반하여 BoneName이 세팅되도록한다.


BaseCharacter.cpp 구현 (모든 캐릭터가 갖고있는 클래스)

2. BaseCharacter.cpp

- BoneName 리턴하는 함수 가져오기 : 플레이어나, 몬스터나 각각 자기 자신의 Bone가져오도록

- bWeaponTrail이면 무기 BoneName을 가져오도록 한다.


Notify세팅

적절한 모션 프레임에 노티파이를 배치하고, 각각 맞는 세팅 값을 넣어준다.


구현결과

sword
staff

-------

+)  UAnimNotifyState_TimedNiagaraEffect  를 상속받아 BP로도 구현 할 수 있다.

 

 

반응형
반응형

왜 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명” 체인 확장 가능
반응형
반응형

데이터 기반 스킬 시스템

이번 화에서는 스킬을 데이터로 제어하는 시스템 구조를 만들었다. 하나의 스킬은 코드가 아니라 데이터 테이블만으로 만들어질 수 있도록.


1. 전체 구조 개요

스킬은 SkillTable 과 ExecTable 두 개의 테이블로 구성된다.

SkillTable : 스킬의 전체 정보(쿨타임,사거리,스킬 타입, 연출Fx, Exec단계 리스트 등)

ExecTable : 각 단계(Exec)에 대한 세부 정보(애니메이션, Fx, 커브, 데칼, 데미지 등)


2. SkillTable - 스킬의 메타 정보

DataId : 고유 ID

CoolTime : 스킬 재사용 대기 시간

SkillRange : 스킬 사용 사거리

SkillTargeting Type : 스킬 대상 타입(NonTarget, ChainAttack 등)

Skill Active Fx : 스킬 전체 공통으로 적용되는 Fx

ExecDatas : 스킬을 구성하는 Exec단계 배열

: ExecDatas배열은 순서대로 실행된다. 예) [0]돌진 -> [1] 공격 -> [2] 피니시 모션. 이렇게 단계를 늘려주면 하나의 스킬을 연속 액션 처럼 구성할 수 있다.


3. ExecTable - 스킬의 세부 처리 스탭

SkillAnim : 실행 애니메이션

Exec Fx / Cascade (Start,End): 시작/종료 Fx

Hit Fx : 타격 Fx

ExecCurve : 캐릭터 이동 /공격 타이밍 커브

Duration : 해당 Exec이 유지 되는 시간

Damage : 데미지

하나의 스킬을 구성하는 여러 Exec들이 순서대로 호출되며, 각 단계별 애니메이션 Fx,커브, 데미지가 달라 질 수 있다.


4. 스킬 실행 흐름

1) 플레이어 입력 (예 : Num1 Key)

2) SkillTable에서 해당 스킬 로드

3) CanPlaySkill() : 현재 상태 체크

4) FindTarget() : 유효 타겟 탐색

5) InitSkillInfo() : ExecInfo 초기화

6) ProcessSkill() : Exec 배열 순회

각Exec이 끝나면 자동으로 다음 단계로 넘어가며, 각 단계의 Duration이나,DelayTime을 통해 타이밍을 제어한다.

 


5. 구조적 장점

1. 확장성 : 새로운 스킬 추가 시 코드 수정이 필요없음, 테이블만 추가/수정하면 새로운 연출 생성 가능하다

2. 조합성 : ExecTable의 조합으로 다양한 스킬 패턴 구현 가능하다.

3. 시각적 조정 용이

반응형
반응형

에임 오프셋

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

이때 "카메라가 보는 방향으로 상체만 조준"이 돼야 활/지팡이 원거리 조준감이 산다. 그래서 컨트롤 로테이션과 엑터 로테이션의 차이를 애니메이션으로 반영하는 "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처리가 된다.

반응형
반응형

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

이번 화 목표

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 언리얼 기능 못씀

 

 

반응형
반응형

 

글자 지우기

https://school.programmers.co.kr/learn/courses/30/lessons/181900

 

코드 : 

string solution(string my_string, vector<int> indices)
{
    vector<char> remove(my_string.size(), 0);
    for (int idx : indices) 
        remove[idx] = 1;

    string answer;
    answer.reserve(my_string.size() - indices.size()); // 재할당 최소화

    for (int i = 0; i < (int)my_string.size(); ++i)
    {
        if (!remove[i]) 
            answer += my_string[i];
    }

    return answer;
}
  • 해결 과정에서의 고민
    • 사실 처음 문제를 봤을때, indices 벡터를 순회하면서 my_string에 원소를 제외하는 방법으로 생각했다. 하지만 indices 개수가 늘어나면 (문자 길이 x 인덱스 개수) 비용이 올라가는 단점이 있었다.
  • 정리 및 느낀 점
    • std::string은 동적 배열(힙 메모리)이라서 할당된 크기를 넘어서면 크기가 재할당되는 구조이기 때문에, 최대 크기를 미리 안다면 reserve로 최대 크기만큼 할당해 놓으면 좋다.
    • 단순히 for문으로 모든 원소를 순회하는것이 아닌, 한번 사용한(마스킹)원소라면 제외 시키는 방법을 먼저 생각 해봐야겠다.

 

 

반응형
반응형

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

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

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


 

소울류 암살(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 등으로 구분
  • 상황에 맞는 애니메이션을 재생하도록 수정

구현 결과


추가 구현 이슈

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

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

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

반응형
반응형

 

머티리얼로 HP Bar 만들기 (UMG용)

1) 머티리얼 생성

  • MT_HPBar 같은 이름으로 새 머티리얼 생성.
  • Material Domain: User Interface (UI 위젯에서 쓸 거라 이걸로)
  • Blend Mode: Translucent (투명도 사용)

UI 도메인은 라이트/쉐이딩 영향 안 받는 Unlit 느낌이라 가볍고, 위젯에서 바로 브러시로 쓸 수 있음.

 

Material Domain

Blend Mode 세팅

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/material-blend-modes-in-unreal-engine

 

2) Texture Sample 두 개

  • 흰 바탕(White) 텍스처: 바가 채워질 기본 색/밝기용.
  • Gradient 마스크 텍스처

3) 노드 연결 

 

  • Final Color(= Base Color): Divide 결과 연결
  • Opacity: Divide 또는 GradientMask 연결

4) 위젯에 적용 (WBP)

  • Image 위젯 추가 → Brush 타입을 Material로 바꾸고 MT_HPBar 지정.
  • Tint 색상으로 최종 컬러 손쉽게 변경 가능(팀/보스별 색 바꾸기 편함).

 

 

5) 결과

  • 텍스처 두 장 + 연산 두 개로 끝.
  • 필요 시 색/밝기 조정은 Tint와 Divide 값으로 빠르게 튜닝.

 

반응형
반응형

오늘은 몹 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로 업그레이드

반응형
반응형

언리얼 프로젝트를 진행하다 보면, 사용하려는 외부 에셋이나 플러그인 때문에 엔진 버전을 올려야 하는 상황이 종종 발생한다.
이번에는 내가 사용하려는 에셋이 5.4 전용이라서, 어쩔 수 없이 프로젝트를 5.3에서 5.4로 마이그레이션하게 되었다.

  1. 버전 업그레이드 절차
    1. 에디터 / Visual Studio 전부 종료
    2. 프로젝트 폴더 내 불필요 파일 삭제
      • Binaries/, Intermediate/, .vs/, *.sln
      • .uproject 우클릭 → Switch Unreal Engine Version… → 5.4 선택 후 확인
    3. 다시 .uproject 우클릭 → Generate Visual Studio project files
    4. Visual Studio에서 .sln 열고 빌드 시도

    2. 발생한 이슈
    • 경로 문제: 기존 5.3에서 정상적으로 참조되던 일부 경로(플러그인/엔진 모듈 등)가 5.4 구조 변경으로 인해 인식을 못했다.
    • 빌드 에러: 특정 매크로나 API가 5.4에서 deprecated 처리되면서, 컴파일 오류가 발생했다.

    3. 해결 방법
    • 경로 문제의 경우, Generate Project Files를 다시 수행하면 대부분 정리된다.
    • 필요 시 Clean Solution → Rebuild를 시도해 캐시 문제까지 싹 정리했다.

    4. 정리 ~~~~~~앞으로는 버전 업그레이드 전,
    • 현재 프로젝트 백업
    • 사용 중인 에셋이 호환 가능한지 확인
      을 습관화해야겠다.
  2. 결국, 프로젝트를 새로운 버전으로 옮길 때는 단순히 "엔진만 바꾼다"로 끝나지 않는다.
    특히 언리얼은 Binaries/Intermediate 캐시에 영향을 많이 받으므로,
    폴더 삭제 → Switch Version → 재생성 순서를 꼭 지켜야 한다는 점을 다시 한번 체감했다.
반응형

'Unreal' 카테고리의 다른 글

unreal) TextBlock 셋팅 방법  (0) 2025.04.05
unreal) Blendspace : AnimGraph 에서 사용하기  (0) 2025.01.01
unreal) blend space  (0) 2025.01.01
unreal) Animaion Blueprint Blend  (0) 2024.12.31
unreal) camera lag  (0) 2024.12.23
반응형

왜 EQS를 쓰게 됐는가

몬스터 AI를 만들다 보니 단순히 플레이어를 보고 달려가서 공격하는 구조만으론 너무 기계적으로 보였다.
공격 모션 자체는 들어가지만, 전투가 뻣뻣하고 리듬감이 전혀 없다. 우리가 흔히 알고 있는 RPG게임의 전투는 이렇지 않다.
공격을 피했다가, 옆으로 게걸음 치듯 돌고, 다시 타이밍을 잡아 공격하는… 그런 살아있는 움직임.

그래서 EQS(Environment Query System)를 쓰기로 했다.
EQS는 주변에 여러 후보 지점을 생성하고, 조건에 맞는 최적의 위치를 선택하게 한다.
즉, “이 상황에서 어디로 움직여야 전투가 자연스러워질까”를 AI 스스로 판단할 수 있는 구조.


사용방법

1. AI → Env Query 생성

 

그러면 아래와 같은 기능을 확인 할 수있다.

 

- Circle, Grid, Cone 등등

나는 플레이어 주변을 원형으로 돌도록 만들고 싶어서 Circle을 선택했다.

 

2. Add Test → 조건 설정

 - 예: Distance(거리)를 조건으로 추가

 

3. EQS Test Pawn 생성

- 시각적으로 어떻게 동작하는지 확인하기 위함

 

그런 다음에 해당 BP에 아까 만들어 놓은 EQS를 세팅한다.

 

4. EnvQueryContext_BlueprintBase 생성

 

  • Query의 기준점을 정의하기 위해 필요

 

  • BP에서 GetActorOfClass 노드 사용 → Player Start 액터를 리턴하도록 세팅

Query에 대상을 Player Start 액터로 지정한다는 의미이다. (스샷에는 없지만 실행 링크까지 연결 해야함) 

 

5. Circle Center에 Env Query 세팅

  • 이렇게 하면 플레이어를 기준으로 Circle 포인트가 생성된다.

 


결과 확인

  •  

  • 원형 구 위의 숫자는 점수다.
  • 이 점수를 기준으로 액터가 어떤 위치로 이동할지 결정할 수 있다.

  • Behavior Tree에서 Run EQS Query를 실행하고, Run Mode를 조정해서 이 점수를 활용한다.
  • 더보기

    Run Mode

    • Single Result
      • 가장 점수가 높은 하나의 위치만 반환
    • Random Best 5% (또는 Best X%)
      • 상위 몇 % 점수 안에 드는 위치들 중 하나를 랜덤으로 선택
    • All Matching
      • 조건을 만족하는 모든 포인트를 반환

정리

EQS를 적용하면서 단순히 “몬스터가 플레이어를 쫓는다”에서 끝나는 AI가 아니라,
조건에 따라 포지션을 스스로 판단하고, 더 자연스러운 전투 움직임을 보여줄 수 있었다.

이 과정을 통해 배운 건 단순히 언리얼 기능 사용법이 아니라,

  • 전투 AI에서 문제(뻣뻣한 움직임)를 인식하고
  • 적절한 툴(EQS)을 찾아 적용하며
  • 테스트와 반복을 통해 결과를 개선했다는 점이다.

 

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/environment-query-testing-pawn-in-unreal-engine

 

 

반응형
반응형

많은 프로젝트에서 싱글톤을 자주 쓰고 있다. 나 역시도 많이 써왔다.
그 이유는 간단하다. 쓰기가 매우 편하다.
언제 어디서든 쉽게 호출할 수 있고, 데이터 캐싱 역할까지 맡기면서 쓰기 좋다.

하지만 이렇게 편리하게 쓰면서도, 그 안에는 여러 가지 문제들이 숨어 있다.
오늘 포스팅에서는 이 문제를 구조적으로 어떻게 해결할 수 있는지, 간단한 로그인 샘플 프로젝트를 만들어서 풀어볼 생각이다.

 

로그인 프로젝트를 예제로 선택한 이유

회사에서 프로젝트를 진행할 때, 가장 초반에 마주하는 게 바로 로그인이다.
특히 빌드 환경(Dev, QA, Live)에 따라 실행 과정, 로그인 플로우, 접속 주소 등이 달라진다.
그만큼 분기 처리가 많아지고, 구조적으로 깔끔하게 정리하지 않으면 관리가 힘들어진다.

게임을 시작하는 첫 번째 관문이 로그인이라 생각했고, 그래서 이번 샘플도 로그인 프로젝트를 기반으로 진행해보기로 했다.

 


그래서 싱글톤에 문제가 뭔데?

아래는 싱글톤으로 로그인 서비스를 구현 내용이다.

// LoginPanel.cs
public class LoginPanel : MonoBehaviour
{
    public void OnClickLogin()
    {
        AuthService.Instance.Login("testId", "1234");
    }
}

// AuthService.cs
public class AuthService
{
    private static AuthService _instance;
    public static AuthService Instance => _instance ??= new AuthService();

    public bool IsLoggedIn { get; private set; }

    public void Login(string id, string pw)
    {
        // Dev, QA, Live 분기를 여기서 직접 처리
#if DEV
        string url = "https://dev-login.server.com";
#elif QA
        string url = "https://qa-login.server.com";
#else
        string url = "https://live-login.server.com";
#endif
        // 실제 로그인 로직 (간단화)
        Debug.Log($"Login Request → {url} : {id}/{pw}");
        IsLoggedIn = true;
    }
}

 

문제점으로 크게 3가지다.

1. AuthService가 싱글톤인 만큼 어디서나 호출 할 수 있다. (뭐 예를 들어 LoginManager.cs이런 매니저류 클래스)

2. 환경 분기 지옥이다. (Dev, QA, Live)가 내부에 하드코딩 되어있다. (지금은 url만 있지만, 환경에 따라 QA는 자동로그인,  DEV는 테스트 로그인만 가능, LIVE는 보안로직 강화 등 분기 지옥이 시작된다)

3. 테스트 환경 변경 어려움 (이건 앞으로 추가적인 예시로 설명 보강하겠다)

 

실무에서 느낀 문제

실제 회사 프로젝트에서 로그인 과정을 싱글톤으로 관리하다 보면, 환경에 따라 다른 주소/설정/로직을 처리하는 코드가 한 군데에 몰린다. 결과적으로:

  • 조건문이 계속 늘어나고
  • 유지보수가 점점 힘들어지며
  • 테스트 환경에서 특정 동작만 분리하기도 어렵다

즉, 편하다고 쓰던 싱글톤이 장기적으로는 프로젝트 속도를 늦추는 원인이 될 수 있다.

 

그~래서 이부분을 해결 할 수 있는 방법이 DI(Dependency Injection)이다.

 


DI(Dependency Injection)란?

DI(의존성 주입)은 말 그대로 객체가 필요한 의존성을 직접 만들지 않고, 외부에서 넣어주는 방식

쉽게 말해서

  • 싱글톤: “내가 필요하면 직접 가져다 쓴다.”
  • DI: “필요한 건 외부에서 준비해주고, 나는 받기만 한다.”

왜 좋은가?

  1. 테스트성
  2. 명시적 의존성
  3. 확장성

DI(의존성 주입)로 해결할 수 있는 포인트

DI는 필요한 객체를 스스로 만들지 않고, 외부에서 주입받는 방식이다.
이를 통해 다음과 같은 개선이 가능하다:

  • 테스트성: 실제 서비스 대신 Mock/Stub 객체를 주입해서 테스트 가능
  • 명시적 의존성: 어떤 클래스가 무엇을 필요로 하는지 코드에 드러남
  • 확장성: Dev, QA, Live 환경별로 다른 객체를 주입하면 깔끔하게 분리 가능

마무리 & 다음 편 예고

이번 글에서는:

  • 싱글톤을 왜 많이 쓰는지
  • 그로 인해 어떤 문제가 발생하는지
  • 로그인 샘플 프로젝트를 예제로 선택한 이유

까지 다뤄보았다.

다음 글에서는 실제로 VContainer를 사용해 DI 환경을 구성하고,
간단한 로그인 플로우를 어떻게 개선할 수 있는지 코드와 함께 살펴볼 예정이다.

 

 

깃허브:

https://github.com/hahahohohun/LoginSample.git

반응형
반응형

왜 썻나?

몬스터 근접/돌진을 할때 타겟까지 남은 거리에 맞는 루트모션 몽타주를 골라야한다. 매번 런타임에 몽타주를 골라야함

매번 런타임에 몽타주를 로드해서 거리를 계산하면 비효율적 -> 빌드/에디터 단계에서 미리 테이블화

Unity에서 에디터 스크립트 하던 걸 언리얼에선 UBlueprintFunctionLibary + AssetRegistry + DataTable로 구현

 


목표 (툴 요구사항)

폴더 기준으로 에셋 스캔 -> UAnimMontage만 필터링

각 몽타주의 루트모션 총 이동거리 계산

결과를 UDataTable(Row:Montage Path, Distance)에 채우거나 갱신

이미 존재하는 Row는 업데이트, 없으면 신규 추가

아티스트/디자이너도 쓸 수 있게 블루프린트 노드로 호출 가능


핵심 설계 포인트

void UPC_BlueprintFunctionLibrary::CollectAssetPaths(const TArray<FString>& Paths, bool bRecursive, UClass* AssetClass,
	TArray<FSoftObjectPath>& OutPaths)
{
	OutPaths.Reset();
	if (!AssetClass || Paths.Num() == 0) return;

	TArray<FName> PackagePaths;
	NormalizeAndScan(Paths, PackagePaths);

	FARFilter Filter;
	Filter.bRecursivePaths = bRecursive;
	Filter.bRecursiveClasses = true;
	Filter.ClassPaths.Add(AssetClass->GetClassPathName());
	
	for (const FName& P : PackagePaths)
		Filter.PackagePaths.Add(P);

	const FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry");
	const IAssetRegistry& AssetRegistry = AssetRegistryModule.Get();

	TArray<FAssetData> Assets;
	AssetRegistry.GetAssets(Filter, Assets);

	TSet<FSoftObjectPath> Unique;
	Unique.Reserve(Assets.Num());
	OutPaths.Reserve(Assets.Num());
	
	for (const FAssetData& AD : Assets)
	{
		OutPaths.AddUnique(AD.ToSoftObjectPath());
	}
}

bool UPC_BlueprintFunctionLibrary::FillDataTableWithRMDistances(UDataTable* DataTable,
	const TArray<FSoftObjectPath>& MontagePaths, bool bClear)
{
	if (!DataTable || DataTable->GetRowStruct() != FPC_AnimMontageRootMotionDistanceRow::StaticStruct())
		return false;

	if (bClear)
		DataTable->EmptyTable();

	// 기존 Path → RowNames 매핑(업데이트용)
	TMap<FString, FName> PathToRowName;
	
	const TArray<FName> RowNames = DataTable->GetRowNames();
	for (const FName& RowName : RowNames)
	{
		if (const FPC_AnimMontageRootMotionDistanceRow* Row = DataTable->FindRow<FPC_AnimMontageRootMotionDistanceRow>(RowName, TEXT(""), false))
		{
			const FString Key = Row->MontagePath.ToString();
			PathToRowName.Add(Key, RowName);
		}
	}
	
	for (const FSoftObjectPath& SoftPath : MontagePaths)
	{
		FString PathStr = SoftPath.ToString();
		TSoftObjectPtr<UAnimMontage> SoftObjectPtr(SoftPath);

		UAnimMontage* Montage = SoftObjectPtr.LoadSynchronous();
		const float Distance = FPC_GameUtil::CalculateRootMotionDistance(Montage);

		if (const FName* Name = PathToRowName.Find(PathStr))
		{
			if (FPC_AnimMontageRootMotionDistanceRow* Existing = DataTable->FindRow<FPC_AnimMontageRootMotionDistanceRow>(*Name, TEXT(""), false))
			{
				Existing->MontagePath = SoftPath;
				Existing->Distance = Distance;
			}
			
			continue;
		}

		// 신규 추가
		FPC_AnimMontageRootMotionDistanceRow NewRow;
		NewRow.MontagePath = SoftPath;
		NewRow.Distance = Distance;

		FName BaseName = SoftPath.GetAssetFName();
		const FName UniqueRowName = MakeUniqueObjectName(DataTable, UDataTable::StaticClass(), BaseName);
		
		DataTable->AddRow(UniqueRowName, NewRow);
		PathToRowName.Add(PathStr, UniqueRowName);
	}

	DataTable->MarkPackageDirty();
	
	return true;
}

 

Asset검색 : FARFilter로 클래스 경로(UAnimMontage) + 패키지 경로 + (옵션) 재귀 스캔

경로 표현 : 런타임 참조 안정성과 리네이밍 대응 위해 FSoftObjectPath사용

중복 처리 :DataTable에서 MontagePath.ToString() -> RowName매핑 캐시 후, 업데이트/신규 분기

Row생성 규칙 :충돌 방지용 MakeUniqueObjectName 사용

Dirty플래그 : 변경 후 MakePackageDirty()로 저장 유도


동작방식

1. 경로 선택

2. 에셋 수집

3. 거리 산출 & 테이블 채우기

4. AI에서 활용


구현 결과

Designer 탭

 

 

Graph 탭

 

RootMotionDistance.Table

 

반응형
반응형

왜 이런 고민을 하게 됐나

게임에서 결제는 단순히 “API 한 번 호출 → 끝”이 아니다.
마켓에 요청을 던지고, 유저가 지문이나 비밀번호를 입력하고, 결과가 서버로 돌아와 아이템까지 지급돼야 비로소 하나의 플로우가 완성된다.

문제는 여기서 시간이다.

  • 네트워크가 끊기면?
  • 서버가 지연되면?
  • 유저가 결제 창 켜놓고 화장실 다녀오면?

이 모든 상황에서 앱이 멈추지 않게 워치독(Watchdog Timer, WDT) 같은 안정장치가 필요했다.
근데 처음 단순히 타임아웃만 걸어놓으니까 진짜 난리가 났다.

“정상 결제인데 WDT가 실패 처리해버린다…”
QA에서 이런 버그가 계속 터졌다. 서비스 상황에서 이런 일이 나면 유저 경험도 박살, 매출도 타격이다.


단순 타임아웃의 함정

내가 처음 만든 워치독은 정말 단순했다.

결제가 시작되면 N초 안에 결과가 안 오면 실패 처리

네트워크 문제는 잘 잡아줬다.
근데 문제는 유저 입력이었다.

PC, 웹결제, 구글 스토어, 애플 스토어, 인증 방식이 다 다르다 보니,
유저가 입력에 오래 걸리면 결제가 정상 진행 중인데도 WDT가 끊어버렸다.
이건 UX 최악이었다.

그때 깨달았다. GPT에게 가이드를 받았다.
“타이머 하나로는 답이 안 된다. 결제 과정을 단계별로 쪼개야 한다.


Phase-aware 설계로 전환

결제를 흐름대로 나눠보니 딱 3단계가 보였다.

  1. Phase A – 요청(Requesting)
    서버와 마켓에 결제 요청을 던지는 구간 → 네트워크 문제에 취약 → WDT ON
  2. Phase B – 사용자 입력(User Input)
    지문, 비번, 앱스토어 팝업에서 유저가 응답하는 구간 → 언제 끝날지 모름 → WDT OFF
  3. Phase C – 결과 처리(Processing)
    마켓 콜백 수신 → 서버 검증 → 아이템 지급 → 네트워크·서버 이슈 대응 필요 → WDT ON

즉,

  • Phase A, C: 워치독 켜두기
  • Phase B: 워치독 꺼두기

이렇게 “Phase-aware”하게 설계하는 게 답이었다.


코드로 옮기면

구현은 상태 머신처럼 심플하게 짰다.

enum PurchasePhase
{
    None,
    PrecheckRpc,          // 서버 사전 체크
    LaunchingSheet,       // 결제창 띄우는 중
    AwaitingUserInput,    // 결제창에서 사용자 입력 (타임아웃 금지)
    VerifyingReceiptRpc,  // 영수증 검증
    AcknowledgeOrConsume, // 승인/소모 처리
    Finished
}
private PurchasePhase _phase = PurchasePhase.None;
private float _lastActivityTime;
public void TouchActivity() => _lastActivityTime = Time.realtimeSinceStartup;

//구현부
IEnumerator PurchaseWatchdog()
{
    while (_phase != PurchasePhase.Finished)
    {
        yield return new WaitForSecondsRealtime(0.5f);

        if (_phase == PurchasePhase.AwaitingUserInput
            continue; // 타임아웃 금지

        var limit = GetPhaseTimeout(_phase);
        if (limit <= 0f) continue;

        float elapsed = Time.realtimeSinceStartup - _lastActivityTime;
        if (elapsed > limit)
        {
            HandleSoftTimeout(_phase); // 부드럽게 중단/재시도/복구 안내
            yield break;
        }
    }
}


_phase = PurchasePhase.PrecheckRpc; 
TouchActivity();
 

네트워크/서버 문제는 그대로 잡아주면서,
유저 입력 시간은 존중할 수 있게 된 셈이다.


결과와 배운 점

  • 유저가 입력을 오래 해도 결제가 끊기지 않았다 → UX 개선
  • 네트워크나 서버 지연은 여전히 워치독이 처리했다 → 안정성 강화
  • 결제 QA 시나리오를 단계별로 나눠서 테스트하기 좋아졌다 → 운영 효율 증가

무엇보다 느낀 건,
워치독은 단순 타이머가 아니라 맥락(Context)을 아는 감시자여야 한다는 거였다.

이 경험은 단순히 결제 로직에만 국한되지 않는다.
멀티스텝으로 진행되는 모든 게임 시스템(예: 매칭, 다운로드, 인증)에 적용할 수 있는 사고 방식이다.
그리고 이런 구조적 문제 해결 경험은 이직 면접에서 “나는 단순 기능 구현자가 아니라 서비스 전체 안정성을 고민하는 개발자다”라는 걸 보여줄 수 있는 좋은 무기가 된다.


마무리

결제 워치독을 Phase-aware 방식으로 바꾼 건 작은 개선처럼 보일 수 있다.
하지만 실제 서비스 현장에서는 유저 경험 + 매출 + QA 효율을 동시에 챙긴 사례였다.

결국 이걸 하면서 내가 얻은 건 “코드를 짠다”를 넘어서,
게임 서비스 전체를 안정적으로 굴릴 수 있는 설계 사고였다.

 

 

 

테스트 시나리오(체크리스트)

  • 결제창 미출력(LaunchingSheet soft timeout)
  • 결제창에서 장시간 대기(입력 단계 타임아웃 없음 확인)
  • 영수증 검증 지연(Verifying soft timeout → 재시도)
  • 승인/소모 지연(Ack/Consume soft timeout → 안내)
  • Android PENDING / iOS Deferred 장기 대기 → 안내 유지, 복구 후 정상 반영
  • 콜백 누락 시에도 루프가 반드시 종료되는지
  • Work.InProgress 해제 대기 타임아웃 동작
  • 스피너가 항상 정확히 켜지고/꺼지는지
  • 목표 수량 도달/초과 시 즉시 중단 (연속구매)
반응형
반응형

회사에서 sdk 적용하는데, 관련 지식이 부족해서 한참 고생했다. 해당 파일들이 정확하게 무슨 기능을하는지 파악하며 작업 진행을 해야한다고 느꼈다. 

 

 

Assets/Plugins/Android/mainTemplate.gradle

 

  • 앱 모듈 수준 build.gradle에 대응합니다.
  • dependencies { ... } 나 android { ... } 등을 통해 SDK 라이브러리, compileOptions 등을 설정합니다.
  • 여기에서 SDK 의존성 (implementation) 을 선언합니다.

 

Assets/Plugins/Android/baseProjectTemplate.gradle

 

 

  • 프로젝트 루트 수준 build.gradle에 대응합니다.
  • 전체 프로젝트의 공통 설정을 담당하며, 주로 allprojects { repositories { ... } } 등을 설정합니다.
  • 외부 저장소 추가(mavenCentral, google(), gradlePluginPortal()) 시 여기에 설정합니다.

 

Assets/Plugins/Android/settingsTemplate.gradle

 

 

  • Android의 settings.gradle에 대응합니다.
  • 여러 모듈이 있을 경우 프로젝트 구조를 정의합니다. 대부분의 Unity 프로젝트에서는 크게 수정하지 않아도 됩니다.
  • 외부 라이브러리 로컬 등록 시 include 항목 조정 가능.

 

 

Assets/Plugins/Android/gradleTemplate.properties

 

  • 또는 Unity에서 생성 후 Library/... 아래

역할

  • Gradle 빌드 시 사용할 전역 프로퍼티 정의
  • 예: HIVE_SDK_VERSION=23.0.0, org.gradle.jvmargs=-Xmx4096M 등
  • Hive SDK와 같은 BOM 사용 시, ${project.HIVE_SDK_VERSION} 값 주입에 사용됨

 

launcher/build.gradle

 

 

 

  • Unity 빌드 시 자동 생성되는 실제 앱 빌드 대상 Gradle 스크립트입니다.
  • 이곳은 직접 수정하지 않고 mainTemplate.gradle의 설정이 여기로 반영됩니다.
  • 수동 수정 시 빌드 후 덮어써지므로 지양해야 합니다.

 

 

 

반응형

'Unity' 카테고리의 다른 글

unity) UniRX  (0) 2024.12.01
[유니티 커스텀 에디터] 맵툴 #2  (0) 2021.08.04
유니티)TCP 채팅 #2  (0) 2021.06.10
유니티)TCP 채팅 #1  (0) 2021.06.09
[유니티] 유니티 PlayerPrefs 저장경로/ Application.dataPath/  (0) 2021.05.03
반응형

왜 스플라인인가?

- 단순 웨이포인트(점과 점 이동)는 코너가 꺾여 보이고 속도 변화가 어색하다.

- 레벨 디자이너가 에디터에서 편집이 가능하다.

- 같은 경로라도 구간별 속도/시야/연출을 다르게 줄 수 있다.

- 언리얼이 쓰라고 만든 기능이다.


구현 과정

1. Patrol BP 만들기 Actor로  구현 (스플라인 컴포넌트를 필드로 가지고있음)

2. 추격 유실 : 최근 스냅샷(패트롤중에 패트롤을 방해해도 다시 원래 패트롤 이동 인덱스로 이동하도록 CPP코드 구현)

3. 스플라인 레벨에 배치

4. 몹 BT에 패트롤 노드 추가

아래는 해당 로직

	EPathFollowingRequestResult::Type PathFollowingRequestResult =
		AIController->MoveToLocation(TargetLocation, 1.f, false);

 


동작 방식

1. 스플라인 엑터를 처음 인덱스 -> 마지막 인덱스 -> 다시 처음 인덱스로 왔다리 갔다리 구현

2. 중간에 시각이나, 청각으로 공격대상 발견 시 타겟을 쫒는 BT실행

3. 타겟 유실 시 다시 패트롤 가고있던 인덱스로 이동

결론 & 추가 구현 예정(로드맵)

  • 지금 단계에서 부드러운 경로 이동 + 전투 전환 + 복귀까지 동작.
  • 다음 업데이트에서 아래 항목을 순차 적용 예정(모두 현 구조에서 무리 없이 확장 가능).
반응형
반응형

애니메이션 커브

왜 넣었나

캐릭터가 목표를 향해 일정 속도로 휙 도는 장면이 생각보다. "게임 같지 않은"티가 났다.

단순 Yaw 보간(RInterpTo등)은 속도 변화가 단조롭고 패턴화돼서, 근육이 붙은 실제 회전처럼 보이지 않았다.


해법

내가 가지고 있는데 에셋에 애니메이션 시퀀스/몽타주에 Curve트랙이 있었고, 그 값으로 회전에 자연스러움을 추가해보려 한다.


세팅 절차

1. 시퀀스/몽타주 열기 -> Curves패널에서 새 Float Curve생성

2. 키 찍기 (내 에셋에서는 디자인되어 있음)

3. Enemy코드에서 이 Curve value 가중치를 이용해 회전 조정하기

if (UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance())
{
	const float CurveValue = AnimInstance->GetCurveValue(TEXT("DistanceToPivot"));
	UE_LOG(LogTemp, Log, TEXT("%.2f"), CurveValue);
	//에셋 자체가 -1이기 때문에
	const float MaxCurveVal = -FMath::Abs(TurnDegree);
			
	// 3. 회전 진행률 (비율)
	float TurnAlpha = 1.f - FMath::Clamp(CurveValue / MaxCurveVal, 0.f, 1.f);  // 0.0 ~ 1.0
			
	// 5. 회전 적용
	float FinalYaw = TurnStartYaw + (TurnDegree * TurnAlpha);
	FRotator NewRot = FRotator(0.f, FinalYaw, 0.f);
	NewRot = FMath::RInterpTo(GetActorRotation(), NewRot, DeltaTime, 10.f);
	SetActorRotation(NewRot);
}

제작 팁

1. 회전 애니메이션 프레임이 너무 길다. => 섹션을 나눈다, 속도 scale 올려준다.


구현결과

 

 

반응형
반응형

GEngine의 정체

GEngine은 전역 포인터(전역 변수)로 존재하는 UEgine인스턴스를 가리킨다. 

말 그대로, 게임 엔진 전체를 관리하는 핵심 엔진 객체에 접근하기 위한 글로벌 핸들 느낌이라고 생각하면 될듯?

UEngine은 추상적인 엔진의 베이스 클래스이고, 실제 실행 중에는 UGameEngine같은 하위 클래스가 생성되어 GEngine에 할당된다.


언제 쓰는지

디버깅, 로그, 시스템 레벨 기능을 직접 호출할때 쓴다.

=> 주로 디버그, 프로토타입, 툴 제작할 때 활용

대표적 예시

AddOnScreenDebugMessage()

  • 화면 위에 직접 텍스트를 띄워줌.
  • 보통 플레이 중 디버깅을 할 때 "눈에 바로 보여야" 할 때 사용.
  • 특징:
    • UI/HUD 위에 표시
    • 에디터 PIE(Play In Editor) 환경에서 바로 확인 가능

key : -1 (새로운 라인에추가), 0(기존 라인에 덮어씌움)

출력되는 위치는 바꿀 수 없다.

 

=>

디버그/테스트만 GEgine쓰고, 실제 전역 컨텍스트는 UGameInstance, UWorld, GetGameInstance()->GetSubsystem<T>(), UEngineSubsystem/UGameInstanceSubsystem/UWorldSubsystem을 쓴다.


런타임에 쓰는 UObject나 Actor와 뭐가 다를까?

GEngine은 전역적으로 엔진 전체 관리 객체이고, Actor나 UObject처럼 게임 월드에 배치되는 개별 오브젝트가 아님,

특정 레벨에 속하지 않아서, 어디서든 접근이 가능한 점


Unity와 비교하면?

유니티에서 1:1로 대체할 전역 엔진 객체는 없는것 같다. 언리얼은 "큰 한 덩어리"를 전역 핸들로 접근하는 문화?가 있고, 유니티는 주요 시스템을 정적 클래스/네임스페이스로 분산한 느낌이다.

유니티에 Debug, Application, Time, SceneMananger...등등

반응형

+ Recent posts