반응형

에임 오프셋

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

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

 

반응형

+ Recent posts