반응형

왜 넣었나?

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

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

유지보수/확장성 : 시각/청각/피해를 표준 이벤트(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. 왜 이 방식이 필요한가? (왜 넣었나)

공통된 기능, 나의 경우에는 플레이어나 몬스터 캐릭터에 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 언리얼 기능 못씀

 

 

반응형
반응형

왜 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

 

 

반응형
반응형

왜 스플라인인가?

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

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

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

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


구현 과정

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

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

3. 스플라인 레벨에 배치

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

아래는 해당 로직

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

 


동작 방식

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

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

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

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

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

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...등등

반응형
반응형

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/unreal-engine-actor-lifecycle

 

왜 중요한가?

엑터의 흐름을 이해야 초기화 Tick 관리, 소멸제어가 가능하다.

액터는 생성 -> 초기화 -> 플레이 시작 -> 소멸의 흐름을 가진다.

 

액터 호출 순서

초기화 단계

1. PreInitalizeComponent() : 컴포넌트 생성 전

2. InitalizeComponent() : 컴포넌트 초기화

3. PostInitializeComponents : 컴포넌트 초기화 완료 후

4. BeginPlay() : 게임 시작 시 최초 호출

 

플레이 중

1. Tick : 매 프레임 실행

2. 이벤트/상태 처리

 

소멸 경로

1. Destroy -> PendingKill표시

2. EndPlay 호출 후 GC에 의해 제거

 

주의점

BeginPlay vs Constructor 차이

1. Constructor : 생성자, 클래스 객체가 메모리에 생성될 때 실행됨, 이 시점에는 World컨텍스트가 완전히 준비되지 않은 경우가 많음, 즉 월드 상태에 의존하는 로직을 쓰면 위험, 기본값 세팅, UPROPERTY 기본 값 지정

2. BeginPlay : 엑터가 월드에 스폰되고, 초기화 과정(PostInitializeComponent()까지 끝난 뒤) 호출 됨, ex) 다른 엑터 찾기, 애니메이션 시작, AI초기화 작업

 

결론

액터의 라이프 사이클 이해는 안정적인 시스템 설계와 최적화의 기본 이다.

반응형

+ Recent posts