https://www.acmicpc.net/problem/11054

 

 

바이토닉 수열 중에 가장 긴 길이를 찾으려면 LIS 알고리즘을 사용하면된다.

이 LIS를 왼쪽에서 오른쪽으로 가면서 i번째를 기준으로 하는 가장 긴 증가하는 부분 수열 길이를 구하고 

오른쪽에서 왼쪽으로 가면서  i번째를 기준으로 하는 가장 긴 감소하는 부분 수열 길이를 더하고 중복되는 i번째를 빼주면 된다.

 

 

정답코드

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

int dp_lis(vector<int>& v)
{
	int size = v.size();
	vector<int> dp1(size, 1), dp2(size, 1);

	//왼쪽->오른쪽
	for (int i = 0; i < size; i++)
	{
		for (int j = 0; j < i; j++)
		{
			if (v[j] < v[i])
			{
				dp1[i] = max(dp1[i], dp1[j] + 1);
			}
		}
	}

	//오른쪽->왼쪽
	for (int i = size - 1; i >= 0; i--)
	{
		for (int j = size - 1; j > i; j--)
		{
			if (v[j] < v[i])
			{
				dp2[i] = max(dp2[i], dp2[j] + 1);
			}
		}
	}

	// 각 `i`에서 `dp1[i] + dp2[i] - 1`의 최댓값 찾기
	int maxLength = 0;
	for (int i = 0; i < size; i++) {
		maxLength = max(maxLength, dp1[i] + dp2[i] - 1);
	}

	return maxLength;
}


int main()
{
	int n;
	cin >> n;
	vector<int>v(n);
	for (int i = 0; i < n; i++)
	{
		cin >> v[i];
	}

	cout << dp_lis(v) << "\n";
}

https://www.acmicpc.net/problem/2156

 

 

n개의 포도주 잔이 있을 때 최대로 마실 수 있는 포도주의 양을 계산해야한다. 

n번째 잔까지의 최적의 값을 누적해나가면서 풀면 된다. 

DP로 풀 경우 초기값을  설정하고 점화식을 도출하자.

3잔 연속해서 마실 수 없기 때문에 

경우의 수를 생각해보면 

우선 i번재 잔을 마신다고 가정했을 때 

i-1번째 잔을 마시면 i-2번째 잔은 마실 수 없기 때문에 i-3번째까지의 최대 값과 더해주면 된다.

i-1번째 잔을 마시지 않으면 i-2번째잔까지의 최대 값과 더해주면 된다.

i번째 잔을 마시지 않으면

i-1번째의 최대 값과 더해주면 된다.

 

이 3가지 경우의 수 중에 제일 큰 값을 저장해 가면서 n까지 값을 갱신하면 된다.

 

최종코드

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

int main()
{
	int n;
	cin >> n;
	vector<int> wine(n + 1, 0);
	vector<int> dp(n + 1, 0);

	for (int i = 1; i <= n; i++) {
		cin >> wine[i];
	}
	//초기값 설정
	dp[1] = wine[1];
	if (n > 1) dp[2] = wine[1] + wine[2];
	if (n > 2) dp[3] = max({ wine[1] + wine[2], wine[1] + wine[3], wine[2] + wine[3] });
	//점화식 3가지 중에 제일 큰걸로
	for (int i = 4; i <= n; i++) {
		dp[i] = max({ dp[i - 1], dp[i - 2] + wine[i], dp[i - 3] + wine[i - 1] + wine[i] });
	}

	cout << dp[n] << "\n";
	return 0;
}

 

https://www.acmicpc.net/problem/10844

 

길이가 N인 계단수를 구해야하는 문제이다. 시간 제한이 1초이기 때문에 당연하게도 브루트 포스하게 풀면 시간초과가 난다. 이러한 문제는 DP를 통해 풀어야한다.

이때 중요한 점은 점화식을 도출해내야하는 것이다. 

쉽게 생각해보면 각 자릿수에서 0일때는 1이 계단수이고 9일때는 8이 계단수이고 나머지 수는 +1 -1이 계단수이기 때문에 초기값을 할당해준 다음 이를 미리 계산해두면 될 것이다.

점화식과 초기값은 다음과 같다

 

이때 1~8까지의 수를 더 해줄때 값이 초과될 수 있기 때문에 문제에서 정의해준 나누는 수로 나눠주자.

또한 N길이의 모든 계단 수를 더 해줄때도 더해주면서 값이 초과할 수 있기 때문에 더해줄때 나눠주는 것을 추가하자.

최종코드

#include <iostream>

#define MOD 1000000000

using namespace std;

int dp[101][10] = { 0 };  //길이 / 자릿수
int main()
{
	int n;
	cin >> n;
	long answer = 0;

	for (int j = 1; j < 10; j++)
	{
		dp[1][j] = 1;		//초기값 설정
	}

	for (int i = 2; i <=n; i++)
	{
		for (int j = 0; j < 10; j++)
		{
			if (j == 0) dp[i][j] = dp[i - 1][j + 1];
			else if (j == 9) dp[i][j] = dp[i - 1][j - 1];
			else dp[i][j] = (dp[i - 1][j + 1]+dp[i - 1][j - 1])%MOD;
		}
	}

	for (int j = 0; j < 10; j++)
	{
		answer = (answer + dp[n][j]) % MOD;;
	}

	cout << answer << endl;

	return 0;
}

 

이제 순찰 중에 플레이어를 만나면 쫓아가도록 구현해보자

이때 Pawn Sensing Component를 통해 플레이어를 감지할 수 있다.

 블루프린트 상에서 추가해보면 다양한 속성을 볼 수 있는데 이때 듣는것과 보는것 모두 감지거리를 조정해줄 수 있다.

LOS는 최대로 들을 수 있는 거리이다.

이를 코드로 구현해보자. 우선 UPawnSensingComponent 를 전방선언해주고 이를 DefaultSubObject로 생성해주고 시야각과 시야범위를 설정해주자. 그리고 플레이어를 감지했을 때의 콜백 함수를 지정해주자.

Enemy.h

private:
	UPROPERTY(VisibleAnywhere)
	class UPawnSensingComponent* PawnSensing;
    
protected:
    UFUNCTION()
	void PawnSeen(APawn* SeenPawn);

Enemy.cpp

AEnemy::AEnemy()
{
    PawnSensing = CreateDefaultSubobject<UPawnSensingComponent>(TEXT("PawnSensing"));
	PawnSensing->SightRadius = 4000.f;
	PawnSensing->SetPeripheralVisionAngle(45.f);
}

void AEnemy::BeginPlay()
{
	Super::BeginPlay();
	if (HealthBarWidget)
	{
		HealthBarWidget->SetVisibility(false);
	}

	EnemyController = Cast<AAIController>(GetController());
	
	MoveToTarget(PatrolTarget);

	if (PawnSensing)
	{
		PawnSensing->OnSeePawn.AddDynamic(this, &AEnemy::PawnSeen);
	}
}

void AEnemy::PawnSeen(APawn* SeenPawn)
{

}

 

이렇게 해준 다음 이제 적의 Patrol Chase Attack 상태를 나누면서  쫓아가는 로직 구현해보자

우선 enum class를 통해 각 state를 나눠주자.

UENUM(BlueprintType)
enum class EEnemyState : uint8
{
	EES_Patrolling UMETA(DisplayName = "Patrolling"),
	EES_Chasing UMETA(DisplayName = "Chasing"),
	EES_Attacking UMETA(DisplayName = "Attacking"),
};

 

이렇게 해준 다음 Enemy클래스에서 이 enum class 변수를 가지고 있게 하자.

Enemy.h

private:
	EEnemyState EnemyState = EEnemyState::EES_Patrolling;

 

그리고 Pawn을 발견했을 때 쫓아가도록 해야한다. 이때 Cast로 검사를 해주게 되면 이 함수의 연산 간격이 짧기 때문에  연산부하가 많이 지게 된다. 이때 특정 태그값을 가지고 있는지 검사해주면 된다. 

SlashCharacter.cpp

void ASlashCharacter::BeginPlay()
{
	Tags.Add(FName("SlashCharacter"));
}

Enemy.cpp

void AEnemy::PawnSeen(APawn* SeenPawn)
{
	if (EnemyState == EEnemyState::EES_Chasing) return;

	//Chase
	if (SeenPawn->ActorHasTag(FName("SlashCharacter")))
	{
		EnemyState = EEnemyState::EES_Chasing;
		GetWorldTimerManager().ClearTimer(PatrolTimer);
		GetCharacterMovement()->MaxWalkSpeed=300.f;
		CombatTarget=SeenPawn;
		MoveToTarget(CombatTarget);
	}
}

 

이렇게 해주면 잘 쫓아오는 것을 알 수 있다.

 

이렇게 해주고 반경 안에서 사리지면 다시 Patrol하도록 해주자.

void AEnemy::CheckCombatTarget()
{
	if (!InTargetRange(CombatTarget, CombatRadius))
	{
		//바깥이면 Patrol로
		CombatTarget = nullptr;
		if (HealthBarWidget)
		{
			HealthBarWidget->SetVisibility(false);
			Attributes->SetHealth(100.f);
		}
		EnemyState = EEnemyState::EES_Patrolling;
		GetCharacterMovement()->MaxWalkSpeed = 125.f;
		MoveToTarget(PatrolTarget);
	}
}

 

이제 여기서 Attack State로 바꾸고 Attack을 할 수 있게 Attack Radius를 지정해주어야 한다. 그리고 이제 CheckCombatTarget함수에서 플레이어와의 거리를 측정해서 Patrol Chase Attack상태가 나눠지도록 조건문을 구성해주자. 이때 PawnSeen함수에서 공격상태가 아닐때만 Chase하도록 해주자. 

Enemy.h

private:
	UPROPERTY(EditAnywhere)
	double AttackRadius = 150.f;

 

Enemy.cpp

void AEnemy::PawnSeen(APawn* SeenPawn)
{
	if (EnemyState == EEnemyState::EES_Chasing) return;

	//Chase
	if (SeenPawn->ActorHasTag(FName("SlashCharacter")))
	{
		GetWorldTimerManager().ClearTimer(PatrolTimer);
		GetCharacterMovement()->MaxWalkSpeed=300.f;
		CombatTarget=SeenPawn;

		if (EnemyState != EEnemyState::EES_Attacking)
		{
			EnemyState = EEnemyState::EES_Chasing;
			MoveToTarget(CombatTarget);
		}
	}
}

void AEnemy::CheckCombatTarget()
{
	if (!InTargetRange(CombatTarget, CombatRadius))
	{
		//바깥이면 Patrol로
		CombatTarget = nullptr;
		if (HealthBarWidget)
		{
			HealthBarWidget->SetVisibility(false);
			Attributes->SetHealth(100.f);
		}
		EnemyState = EEnemyState::EES_Patrolling;
		GetCharacterMovement()->MaxWalkSpeed = 125.f;
		MoveToTarget(PatrolTarget);
	}
	else if (!InTargetRange(CombatTarget, AttackRadius) && EnemyState != EEnemyState::EES_Chasing)
	{
		//공격반경 바깥
		EnemyState = EEnemyState::EES_Chasing;
		GetCharacterMovement()->MaxWalkSpeed = 300.f;
		MoveToTarget(CombatTarget);
	}
	else if (InTargetRange(CombatTarget, AttackRadius) && EnemyState != EEnemyState::EES_Attacking)
	{
		//공격 반경 내부
		EnemyState = EEnemyState::EES_Attacking;
		//공격
	}
}

 

하지만 지금만약 적의 뒤에서 공격을 하면 아무 반응이 없다. 이러한 점을 고쳐보도록 하자. 이는 TakeDamage함수에서 지정해줄 수 있다.

float AEnemy::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
	if (Attributes)
	{
		Attributes->ReceiveDamage(DamageAmount);
		HealthBarWidget->SetHealthPercent(Attributes->GetHealthPercent());
	}

	CombatTarget = EventInstigator->GetPawn();
	EnemyState = EEnemyState::EES_Chasing;
	GetCharacterMovement()->MaxWalkSpeed = 300.f;
	MoveToTarget(CombatTarget);

	return DamageAmount;
}

 

마지막으로 적에게 무기를 장착하기 위해 다양한 무기들을 추가해보자. 이때 무기 에셋마다 각 Origin(Pivot)이 다르기 때문에 조심해야한다.

우선 무료 무기 에셋 중에 Free Fantasy Weapon Sample Pack이라는 에셋을 추가하여 사용해보자. 무기 중에 Axe를 보면 원점이 손잡이에 있는 것을 볼 수 있다.

https://www.fab.com/ko/listings/d5be0dc9-1a41-4be2-a63a-5ed436f3445d

 

이 Axe로 매쉬를 변경하고 실행해서 잡아보면 문제가 생기는 것을 알 수 있다. 손에서 떨어져 있으며 도끼의 방향도 잘 못된것을 알 수 있다.

 

이렇게 된 무기들의 원점을 동일하게 바꿔주어야하는데 이를 위해 Blender를 사용하자. Blendor에서 사용하려면 해당 에셋을 Export해주어야한다.

 

Blender에서는 fbx 파일을 가져와서 에디트 모드로 a키를 눌러 모두 선택한 다음들어간다음 R키를 누르고 Y키를 눌러 y축이 고정된 상태로 180도 회전시켜주면된다. 그리고 G키와 Z키를 통해 원점을 Z축이 고정된 상태로 손잡이로 바꾸주자.

마지막으로 크기는 S키를 누르면 변경할 수있다. 완성된 fbx파일은 이름을 기존 것과 다르게 해준 다음 다시 언리얼에서 임폴트 해주자.

 

임폴트한 에셋에 머티리얼은 이전것과 동일한 것으로 지정해주자. 이렇게 해주면 이전과 다르게 잘 잡고 있는 것을 볼 수 있다.

'게임공부 > Unreal Engine' 카테고리의 다른 글

[Unreal Engine][C++]25. Patrol  (0) 2025.02.07
[Unreal Engine][C++]24. Enemey Behavior2  (0) 2025.02.05
[Unreal Engine][C++]23. Enemey Behavior  (0) 2025.01.27
[Unreal Engine][C++]22. Death  (1) 2025.01.26
[Unreal Engine][C++]21. Damage  (1) 2025.01.21

https://www.acmicpc.net/problem/11066

 

DP를 활용해서 풀 수 있는 문제이다.

우선 어떤 값을 동적 계획을 통해 미리 계산해둘지 생각해보면 지금 필요한 것이 i번째부터 j번째까지 파일을 합칠 때의 최소비용을 구해야하기 때문에 결과적으로 dp배열에는 최소비용이 들어가야한다.

 

  • dp[i][j] = i번째부터 j번째 파일까지 합치는 최소 비용
  • sum[i][j] = i~j까지의 파일 크기의 합 (누적합 사용)

DP 점화식은 다음과 같다.

 

dp[i][j] = min(dp[i][k] + dp[k+1][j] + sum[i][j]) (i ≤ k < j)

 

 

  • dp[i][k] : 왼쪽 부분을 먼저 합친 최소 비용
  • dp[k+1][j] : 오른쪽 부분을 먼저 합친 최소 비용
  • sum[i][j] : 두 개를 합치는 비용

 

최종 DP 알고리즘 진행 순서

  1. sum[i][j]을 미리 계산해서 누적합을 저장 (sum[i][j] = sum[i][j-1] + size[j]).
  2. 파일 길이(구간 크기) 2부터 K까지 증가시키면서 DP를 계산.
  3. 최소 비용을 구하기 위해 구간을 나누는 모든 경우를 탐색 (dp[i][k] + dp[k+1][j] + sum[i][j]). -> 순환 탐색 i~j까지
  4. 최종적으로 dp[1][K] 가 정답.

 

 

최종코드

#include <iostream>
#include <vector>
#include <climits>

using namespace std;

int main() {
    int t;
    cin >> t;

    while (t--) {
        int k;
        cin >> k;

        vector<int> files(k + 1);
        vector<vector<int>> dp(k + 1, vector<int>(k + 1, 0));
        vector<int> prefixSum(k + 1, 0);

        // 파일 크기 입력 & 누적합(prefix sum) 계산
        for (int i = 1; i <= k; i++) {
            cin >> files[i];
            prefixSum[i] = prefixSum[i - 1] + files[i]; // 누적합 저장
        }

        // DP 테이블 채우기 (구간 길이 2부터 K까지)
        for (int len = 2; len <= k; len++) { // 파일을 합치는 구간 길이
            for (int i = 1; i + len - 1 <= k; i++) { // 시작점
                int j = i + len - 1; // 끝점
                dp[i][j] = INT_MAX; // 최소값을 찾기 위해 큰 값으로 초기화

                // 최적의 분할점을 찾기
                for (int mid = i; mid < j; mid++) {
                    dp[i][j] = min(dp[i][j], dp[i][mid] + dp[mid + 1][j] + (prefixSum[j] - prefixSum[i - 1]));
                }
            }
        }

        // 정답 출력 (전체 파일을 합치는 최소 비용)
        cout << dp[1][k] << '\n';
    }

    return 0;
}

 

이제 단일 지점으로의 Patrol은 가능한 것을 볼 수 있다. 이제 한 Patrol Point에서 다른 Patrol Point로 이동할 수 있도록 구현해보자.

이를 위해 타켓포인트를 여러개 만들어주고 배열에 할당해주자

 

그리고 Tick함수에서 Target과의 거리를 측정해주는 함수가 작동할 수 있도록 Actor와의 지정해준 거리에 따라 bool값을 반환해주는 새로운 함수를 만들어주자.

Enemy.cpp

bool AEnemy::InTargetRange(AActor* Target, double Radius)
{
	const double DistanceToTarget = (Target->GetActorLocation() - GetActorLocation()).Size();
	return DistanceToTarget <= Radius;
}

 

이를 바탕으로  Tick함수에서 HealthBar 조건문도 변경해주고 PatrolTarget조건문도 만들어주자.

이때 PatrolRadius(멈출 거리)를 지정해주어야하는데 이 값이 double인데 moveTo에서는 이 반경보다 조금 더 이동하기 때문에 조금 더 거리를 지정해주어야한다.

Enemy.h

	UPROPERTY(EditAnywhere)
	double PatrolRadius = 200.f;

Enemy.cpp

void AEnemy::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	if (CombatTarget)
	{
		if (!InTargetRange(CombatTarget,CombatRadius))
		{
			CombatTarget = nullptr;
			if (HealthBarWidget)
			{
				HealthBarWidget->SetVisibility(false);
				Attributes->SetHealth(100.f);
			}
		}
	}

	if (PatrolTarget && EnemyController)
	{
		TArray<AActor*> ValidTargets;
		for (AActor* Target : PatrolTargets)
		{
			if (Target != PatrolTarget)
			{
				ValidTargets.AddUnique(Target);
			}
		}


		if (InTargetRange(PatrolTarget, PatrolRadius))
		{
			const int32 TargetSelection = FMath::RandRange(0, ValidTargets.Num() - 1);
			PatrolTarget = ValidTargets[TargetSelection];
			
			FAIMoveRequest MoveRequest;
			MoveRequest.SetGoalActor(PatrolTarget);
			//도착이라고 생각할 거리
			MoveRequest.SetAcceptanceRadius(15.f);
			EnemyController->MoveTo(MoveRequest);
		}
	}
}

 

이렇게 해주고 실행해주면 랜덤한 순찰포인트로 순찰하는 것을 볼 수 있다.

 

하지만 지금은 순찰 포인트로 이동하고 바로 다른 포인트로 이동하는데 이것을 조금 기다렸다가 다른 순찰포인트로 이동하도록 구현해보자. 

이를 위해 FTimerHandle 구조체 변수를 선언해주자. 이 구조체 변수는 일정 시간이 지난후  호출될 콜백함수가 필요하기 때문에 함수도 같이 선언해주자.

Enemy.h

private:
	FTimerHandle PatrolTimer;
	void PatrolTimerFinished();

 

Enemy.cpp

void AEnemy::PatrolTimerFinished()
{
	MoveToTarget(PatrolTarget);
}

 

이렇게 콜백함수를 만들어준 다음 기능별로 나눌 수 있는것은 나눠주자.

Enemy.cpp

bool AEnemy::InTargetRange(AActor* Target, double Radius)
{
	if (Target == nullptr) return false;
	const double DistanceToTarget = (Target->GetActorLocation() - GetActorLocation()).Size();
	DRAW_SPHERE_SingleFrame(GetActorLocation());
	DRAW_SPHERE_SingleFrame(Target->GetActorLocation());
	return DistanceToTarget <= Radius;
}

void AEnemy::MoveToTarget(AActor* Target)
{
	if (EnemyController == nullptr || Target == nullptr) return;

	FAIMoveRequest MoveRequest;
	MoveRequest.SetGoalActor(Target);
	//도착이라고 생각할 거리
	MoveRequest.SetAcceptanceRadius(15.f);
	EnemyController->MoveTo(MoveRequest);
}

AActor* AEnemy::ChoosePatrolTarget()
{
	TArray<AActor*> ValidTargets;
	for (AActor* Target : PatrolTargets)
	{
		if (Target != PatrolTarget)
		{
			ValidTargets.AddUnique(Target);
		}
	}

	if (ValidTargets.Num() > 0)
	{
		const int32 TargetSelection = FMath::RandRange(0, ValidTargets.Num() - 1);
		return ValidTargets[TargetSelection];
	}
	return nullptr;
}

 

만약 타켓이 거리안에 있다면 5초를 대기한 다음 이동하게 해주자 이때 GetWorldTimerManager의 SetTimer 기능을 활용하면 된다.

Enemy.cpp

void AEnemy::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
	
	if (InTargetRange(PatrolTarget, PatrolRadius))
	{
		PatrolTarget = ChoosePatrolTarget();
		GetWorldTimerManager().SetTimer(PatrolTimer, this, &AEnemy::PatrolTimerFinished, 5.f);
	}
	
}

 

이렇게 해주면 5초를 대기한 다음 다음 순찰포인트로 이동한다.

 



이전 시간에 애니메이션을 임포트해주었으니 이제 올바른 상황에 맞게 출력되도록 설정해주자 

애니메이션 블루프린트의 Main State에 Blend Space를  사용하는 것으로 걷고 뛰는 애니메이션이 재생되도록 해주자.

이때 Blend Space는 속도와 같은 변수 값의 상태에 따라 다른 애니메이션이 재생되도록 해준다.

이를 위해 애니메이션/블랜드스페이스1D를 생성해주자.

가로축의 이름을 GroundSpeed로 바꿔주고 최소를0 최대를 300으로 해준다음 Idle Walk Run을 배치해주자

 

이렇게 배치해준다음 Idle State에서 애니메이션 포즈가 이 Blend Space를 통해 출력되도록 수정해주자

 

이렇게 해주고 이제 적의 속도를 가져와야하기 때문에 이벤트그래프에서 적캐릭터 BP를 가져오고 BP에 있는 Character Movement도 가져오도록 수정해주자.

 

 

그리고 Float로 GroundSpeed라는 변수를 선언해주고 Thread Safe Update Animation에서 이 GroundSpeed를 설정해줄 수 있도록 해주자.

 

그 다음, 이 변수 값을 가져와서 Blend Space 입력에 넣어주자

이렇게 해주면 움직임이 한쪽으로 치우쳐보이는데 이는 Character에서 Orient 값을 체크해주고 BP_Enemy의 컨트롤러 요 사용을 꺼주면 된다.

 

이를 코드로 구현해보자

AEnemy::AEnemy()
{
	PrimaryActorTick.bCanEverTick = true;

	GetMesh()->SetCollisionObjectType(ECollisionChannel::ECC_WorldDynamic);
	GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block);
	GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);
	GetMesh()->SetGenerateOverlapEvents(true);
	GetCapsuleComponent()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);

	Attributes = CreateDefaultSubobject<UAttributeComponent>(TEXT("Attributes"));
	HealthBarWidget = CreateDefaultSubobject<UHealthBarComponent>(TEXT("HealthBar"));
	HealthBarWidget->SetupAttachment(GetRootComponent());

	GetCharacterMovement()->bOrientRotationToMovement = true;
	bUseControllerRotationPitch = false;
	bUseControllerRotationRoll = false;
	bUseControllerRotationYaw = false;
}

 

걷는 모션이 생겼으니 이제 특정 포인트를 순찰하도록 만들어보자. 여러 포인트를 가진 다음 상황에 따라 특정 포인트를 선택하여 순찰하도록 구현해보자. 이를 위해 AActor값의 TArray를 만들어주자.

 

Enemy.h

private:
	UPROPERTY(EditInstanceOnly,Category="AI Navigation")
	AActor* PatrolTarget;

	UPROPERTY(EditInstanceOnly, Category = "AI Navigation")
	TArray<AActor*> PatrolTargets;

 

이를 활용하기 위해 AAIController의 MoveTo함수를 사용하자.

 

이를 사용하려면 모듈에 AIModule을 추가해줘야한다. 이렇게 해준 다음 순찰타켓을 지정해주고 옵션값을 지정해준 다음 OutPath를 가져와서 DebugSphere을 그려주게 하자.

Enemy.cpp

void AEnemy::BeginPlay()
{
	Super::BeginPlay();
	if (HealthBarWidget)
	{
		HealthBarWidget->SetVisibility(false);
	}

	EnemyController = Cast<AAIController>(GetController());
	if (EnemyController && PatrolTarget)
	{
		FAIMoveRequest MoveRequest;
		MoveRequest.SetGoalActor(PatrolTarget);
		//도착이라고 생각할 거리
		MoveRequest.SetAcceptanceRadius(15.f);
		FNavPathSharedPtr NavPath;
		EnemyController->MoveTo(MoveRequest, &NavPath);
		//& : 복사x -> 참조
		TArray<FNavPathPoint>& PathPoints = NavPath->GetPathPoints();
		for (auto& Point : PathPoints)
		{
			const FVector& Location = Point.Location;
			DrawDebugSphere(GetWorld(), Location, 12.f, FColor::Green, false, 10.f);
		}
	}
}

 

이렇게 해준다음 BP에서 Patrol Point를 지정해주면 된다.

 

이렇게 해주고 실행하면 직선이기 때문에 구가 2개만 등장하는 것을 볼 수 있다.

만약 여기서 장애물이나 벽을 추가하면 구가 늘어나게 된다.

'게임공부 > Unreal Engine' 카테고리의 다른 글

[Unreal Engine][C++]26. Patrol2  (0) 2025.02.10
[Unreal Engine][C++]25. Patrol  (0) 2025.02.07
[Unreal Engine][C++]23. Enemey Behavior  (0) 2025.01.27
[Unreal Engine][C++]22. Death  (1) 2025.01.26
[Unreal Engine][C++]21. Damage  (1) 2025.01.21

https://www.youtube.com/watch?v=XhfB3ZS3JoM&list=LL&index=5&t=11139s

이번에 유투브 강의영상을 보고 따라 만들어 보기로 하였다. 여기서 똑같이 만드는 것이 아닌 내 방식대로 만들어보려고 한다.

그리고 여기에 내가 응원하는 배우님이 카페에 올려주신 사진을 활용해서 카드 짝 맞추기 게임을 만들어 볼 것이다.

구현 순서는 영상에 나오는 순서대로 구현을 해보도록 할 것이다.

1.카드 및 보드 구현

우선 카드 객체를 구현해보자 필요한 기능은 뒤집는 기능이 필요하다. 이 기능은 Dotween과 IPointerClickHandler를 사용하여 구현하였다.

Card.cs

using UnityEngine;
using UnityEngine.EventSystems;
using DG.Tweening;

public class Card : MonoBehaviour, IPointerClickHandler
{
    [SerializeField] private SpriteRenderer spriteRenderer;
    [SerializeField] private Sprite frontSprite;
    [SerializeField] private Sprite backSprite;
    private Board board;
    private bool isFlipped = false;
    private bool isAnimating = false;

    private void Awake()
    {
        spriteRenderer = GetComponent<SpriteRenderer>();

    }

    public void SetCard(Sprite sprite, Board board)
    {
        frontSprite = sprite;
        this.board = board;
        backSprite = Resources.Load<Sprite>("Sprites/Front/Front"); // 뒷면 기본 이미지
        spriteRenderer.sprite = backSprite;
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        if (board.GetSelectedCardCount() >= 2 || isFlipped || isAnimating)
            return; // 이미 2장이 선택되었으면 클릭 방지

        FlipCard();
        board.SelectCard(this);
    }


    public void FlipCard()
    {
        if (isAnimating) return;
        isAnimating = true;

        Vector3 targetScale = new Vector3(0f, transform.localScale.y, transform.localScale.z);


        transform.DOScale(targetScale, 0.2f).OnComplete(() =>
        {
            spriteRenderer.sprite = isFlipped ? backSprite : frontSprite;
            isFlipped = !isFlipped;

            transform.DOScale(Vector3.one, 0.2f).OnComplete(() =>
            {
                isAnimating = false;
            });
        });


    }

    public void FlipBack()
    {
        if (!isFlipped) return;
        FlipCard();
    }

    public Sprite GetSprite()
    {
        return frontSprite;
    }

    public bool IsFlipped()
    {
        return isFlipped;
    }

}

 

보드는 모든 카드의 위치를 관리해주고 룰을 결정해준다. 카드는 4*5배열로 배치되게 하였다. 

Board.cs

using UnityEngine;
using System.Collections.Generic;
using System.Linq;

public class Board : MonoBehaviour
{
    [SerializeField] private GameObject cardPrefab;
    [SerializeField] private Sprite[] cardSprites; // 카드 앞면 (1~6번 이미지)
    private List<Card> cards = new List<Card>();
    private List<Card> selectedCards = new List<Card>(); // 선택된 카드들

    private int rowCount = 5;           //세로
    private int colCount = 4;           //가로
    private float xStart = -2.1f;
    private float yStart = 3.3f;
    private float xSpacing = 1.4f;
    private float ySpacing = -1.8f;

    private void Awake()
    {
        cardPrefab = Resources.Load<GameObject>("Prefabs/Card/Card");
        LoadSprites();
        ShuffleCards();
        InitBoard();
    }

    void LoadSprites()
    {
        // Resources 폴더에서 "Sprites/0~9" 로드
        cardSprites = new Sprite[10];
        for (int i = 0; i < 10; i++)
        {
            cardSprites[i] = Resources.Load<Sprite>($"Sprites/Back/{i}");
        }
    }

    public void ShuffleCards()
    {
        List<Sprite> tempSprites = new List<Sprite>();

        // 0~9번 카드 각각 2장씩 추가
        foreach (var sprite in cardSprites)
        {
            tempSprites.Add(sprite);
            tempSprites.Add(sprite);
        }

        // 랜덤 섞기
        tempSprites = tempSprites.OrderBy(x => Random.value).ToList();

        // cards 리스트에 카드 추가
        cards.Clear();
        for (int i = 0; i < tempSprites.Count; i++)
        {
            GameObject newCard = Instantiate(cardPrefab, Vector3.zero, Quaternion.identity, this.transform);
            Card card = newCard.GetComponent<Card>();
            card.SetCard(tempSprites[i], this);
            cards.Add(card);
        }
    }


    public void InitBoard()
    {
        int index = 0;
        for (int i = 0; i < rowCount; i++)
        {
            for (int j = 0; j < colCount; j++)
            {
                if (index >= cards.Count) return;  // 카드 개수 초과 방지

                // 위치 설정
                Vector3 pos = new Vector3(xStart + (xSpacing * j), yStart + (ySpacing * i), 0);

                // 기존에 생성된 카드 객체를 위치만 변경
                cards[index++].transform.position = pos;

            }
        }
    }


    public void SelectCard(Card card)
    {
        if (selectedCards.Contains(card) || selectedCards.Count >= 2)
            return;

        selectedCards.Add(card);

        if (selectedCards.Count == 2)
        {
            CheckMatch();
        }
    }

    void CheckMatch()
    {
        if (selectedCards.Count < 2) return; // 두 장 선택되지 않으면 비교 불가

        if (selectedCards[0].GetSprite() == selectedCards[1].GetSprite())
        {
            // 같은 카드라면 유지
            selectedCards.Clear();
            Managers.Audio.PlaySound("Match");  // 카드 맞추면 효과음 재생!
        }
        else
        {
            // 다른 카드라면 1초 후 다시 뒤집기
            Invoke(nameof(ResetCards), 1f);
        }
    }

    void ResetCards()
    {
        foreach (var card in selectedCards)
        {
            card.FlipBack();
        }
        selectedCards.Clear();
    }


    public int GetSelectedCardCount() => selectedCards.Count;
    public List<Card> GetCards() => cards;
}

 

실제로 배치된 모습을 보면 다음과 같다

 

이제 GameManager를 통해 이 보드의 초기화 함수인 Init을 호출해주고 코루틴 함수를 통해 모든 카드를 오픈하고 다시 뒤집어서 유저가 게임을 진행할 수 있게 한다. 또한 시간을 관리해주고 게임이 이겼는지 확인해준다. 

GamaManager.cs

using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.PlayerLoop;
using UnityEngine.UI;
using static UnityEngine.RuleTile.TilingRuleOutput;

public class GameManager
{
    private Board board;
    private List<Card> cards;
    private bool isGameActive = false;
    private float gameTime = 60f; // 총 게임 시간
    private float remainingTime; // 현재 남은 시간

    private Slider timeSlider;
    private Image sliderFill; // 슬라이더의 Fill 색상 변경용
    private TextMeshProUGUI timeText; // 남은 시간을 표시하는 UI
    public event Action<float> OnTimeUpdated;

    public void Init()
    {
        board = GameObject.Find("Board")?.GetComponent<Board>();
        timeSlider = GameObject.Find("TimeOutSlider")?.GetComponent<Slider>();
        timeText = GameObject.Find("TimeOutText")?.GetComponent<TextMeshProUGUI>();

        if (board == null || timeSlider == null || timeText == null)
        {
            Debug.LogError("GameManager 초기화 실패 - 필수 UI 요소가 없음.");
            return;
        }

        // 게임 시작
        isGameActive = true;

        sliderFill = timeSlider.fillRect.GetComponent<Image>();
        remainingTime = gameTime;

        CoroutineHelper.StartCoroutine(StartGameSequence());
    }


    IEnumerator StartGameSequence()
    {
        // 보드가 초기화될 시간을 기다림
        yield return new WaitForSeconds(0.3f);
        cards = board.GetCards();

        // 모든 카드 공개 (처음 1초 동안)
        foreach (var card in cards)
        {
            card.FlipCard();
        }
        yield return new WaitForSeconds(1.5f);

        // 다시 뒤집기
        foreach (var card in cards)
        {
            card.FlipBack();
        }

        yield return new WaitForSeconds(0.3f);

        // 타이머 UI 활성화
        timeSlider.gameObject.SetActive(true);
        timeText.gameObject.SetActive(true);
        Managers.Audio.PlayBGM("BGM");

  
        CoroutineHelper.StartCoroutine(UpdateTimer());
    }

    IEnumerator UpdateTimer()
    {
        while (remainingTime > 0 && isGameActive)
        {
            remainingTime -= Time.deltaTime;
            timeSlider.value = remainingTime;
            OnTimeUpdated?.Invoke(remainingTime); // UI 업데이트 호출

            if (CheckWinCondition())
            {
                GameOver(true);
                yield return new WaitForSeconds(1f);
                yield break;
            }

            yield return null;
        }

        if (remainingTime <= 0)
        {
            GameOver(false);
        }
    }

    private bool CheckWinCondition()
    {
        foreach (var card in board.GetCards())
        {
            if (!card.IsFlipped()) return false;
        }
        return true;
    }

    private void GameOver(bool isWin)
    {
        isGameActive = false;
        Time.timeScale = 0.0f;
        CoroutineHelper.StartCoroutine(GameOverSequence(isWin));
    }

    private IEnumerator GameOverSequence(bool isWin)
    {
        yield return new WaitForSecondsRealtime(0.5f); // 0.5초 딜레이 후 실행

        // DOTween의 모든 트위닝을 제거
        DG.Tweening.DOTween.KillAll();

        if (isWin)
        {
            Managers.UI.ShowPopupUI<UI_Success>();
        }
        else
        {
            Managers.UI.ShowPopupUI<UI_GameOver>();
        }
    }

}

 

그리고 게임에 필요한 UI는 UI_Game으로 묶어서 관리해주도록 했다. 옵저버 패턴을 사용하여 시간이 지남에 따라 색깔이 바뀌고 슬라이더의 바가 줄어들도록 구현했다. 이때 각 오브젝트의 이름과 Bind하는 enum 변수들의 이름이 같아야 정상적으로  Bind가 이루어진다.

UI_Game.cs

using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class UI_Game : UI_Scene
{

    enum Buttons
    {
        EscapeButton,
    }

    enum Texts
    {
        TimeOutText,
    }

    enum GameObjects
    {
        TimeOutSlider,
    }

    private float gameTime = 60f; // 총 게임 시간


    public override void Init()
    {
        base.Init(); // 상위 클래스의 초기화 메서드 호출

        Bind<Button>(typeof(Buttons));
        Bind<TextMeshProUGUI>(typeof(Texts));
        Bind<GameObject>(typeof(GameObjects));

        GetObject((int)GameObjects.TimeOutSlider).GetComponent<Slider>().maxValue = gameTime;
        GetObject((int)GameObjects.TimeOutSlider).GetComponent<Slider>().value = gameTime;
        GetButton((int)Buttons.EscapeButton).gameObject.AddUIEvent(PauseOrResume);

        Managers.Game.OnTimeUpdated += UpdateTimeUI;
    }

    void PauseOrResume(PointerEventData eventData)
    {
        // 1. 뭐든지 열려있으면 다 닫기
        // 2. 아무것도 없으면 열기

        if (Managers.UI.GetStackSize() > 0)
            Managers.UI.CloseAllPopupUI();
        else
            Managers.UI.ShowPopupUI<UI_PausePopup>();
    }

    private void UpdateTimeUI(float time)
    {
        GetText((int)Texts.TimeOutText).text = Mathf.CeilToInt(time).ToString();
        GetObject((int)GameObjects.TimeOutSlider).GetComponent<Slider>().value = time; 
        UpdateTimeColor(time);
    }


    private void UpdateTimeColor(float time)
    {
        float normalizedTime = time / gameTime;
        Color startColor = new Color(0.96f, 0.55f, 0.0f);
        Color endColor = new Color(1.0f, 0.0f, 0.0f);
        Color timeColor = Color.Lerp(endColor, startColor, normalizedTime);

        GetText((int)Texts.TimeOutText).color = timeColor;
        GetObject((int)GameObjects.TimeOutSlider).GetComponent<Slider>().fillRect.GetComponent<Image>().color = timeColor;
    }

    private void OnDisable()
    {
        Managers.Game.OnTimeUpdated -= UpdateTimeUI;
    }
}

 

UI구성은 다음과 같다. 


이제 적에 대한 코드를 구현해보자. 우선 적이 어떤 상태를 가지게 될지 생각해보자.

간단한 쯔구르게임이기 때문에 가장 기본인 순찰, 추적, 공격상태가 있을 것이다. 우선은 순찰 및 추적까지 구현해보자.

이를 위해 상태 인터페이스를 구현해주자. 2개의 PatrolPoint에서 하나씩 목적지로 선정하여 가까워지면 다른 포인트로 이동하게 구현하였다.

IMobState.cs

using UnityEngine;

public interface IMobState
{
    void Enter(BaseMobController mob);      // 상태 진입
    void Execute();                 // 상태 실행
    void Exit();                    // 상태 종료
}

public class PatrolState : IMobState
{
    private BaseMobController _mob;
    private Vector2 currentTargetPoint;
    private Vector2 patrolPointA;
    private Vector2 patrolPointB;

    public PatrolState(Vector2 pointA, Vector2 pointB)
    {
        patrolPointA = pointA;
        patrolPointB = pointB;
        currentTargetPoint = patrolPointA;
    }

    public void Enter(BaseMobController mob)
    {
        this._mob = mob;
        _mob.SetDestination(currentTargetPoint);
    }

    public void Execute()
    {
        if (_mob.IsPlayerDetected())
        {
            _mob.ChangeState(new ChaseState());
            return;
        }

        if (Vector2.Distance(_mob.transform.position, currentTargetPoint) < 1f)
        {
            currentTargetPoint = (currentTargetPoint == patrolPointA) ? patrolPointB : patrolPointA;
            _mob.SetDestination(currentTargetPoint);
        }

        _mob.Move(currentTargetPoint);
    }

    public void Exit()
    {

    }
}

public class ChaseState : IMobState
{
    private BaseMobController _mob;
    private Transform playerTransform;

    public void Enter(BaseMobController mob)
    {
        this._mob = mob;
        playerTransform = Managers.Game.GetPlayer().transform;
    }

    public void Execute()
    {
        float distanceToPlayer = Vector2.Distance(_mob.transform.position,playerTransform.position);
        if(distanceToPlayer <= _mob.GetAttackRange())
        {
            _mob.ChangeState(new AttackState());
        }else if(distanceToPlayer > _mob.GetAttackRange())
        {
            Vector2 pointA = _mob.GetPatrolPointA();
            Vector2 pointB = _mob.GetPatrolPointB();
            _mob.ChangeState(new PatrolState(pointA, pointB));
        }else
        {
            _mob.Move(playerTransform.position);
        }
    }

    public void Exit()
    {
        
    }
}

 

그리고 다양한 Mob이 있을 수 있기때문에 Mob이 가지고 있어야  할 함수와 변수를 남은 abstract 객체를 선언해주자. 그리고 기본적인 정보는 FlyWeight 패턴을 사용하여 Scriptable Object를 참조하도록  했다.

BaseMobController.cs

using System.Collections.Generic;
using UnityEngine;

public abstract class BaseMobController : MonoBehaviour
{
    protected IMobState currentState;
    public MobData mobData;

    public IMobState GetCurrentState()
    {
        return currentState;
    }

    public abstract void ChangeState(IMobState state);
    public abstract float GetDetectionRange();
    public abstract float GetAttackRange();
    public abstract float GetChasableRange();
    public abstract Vector2 GetPatrolPointA();
    public abstract Vector2 GetPatrolPointB();
    public abstract void SetDestination(Vector2 destination);
    public abstract void SetPatrolPoints(Vector2 pointA, Vector2 pointB);
    public abstract bool IsPlayerDetected();
    public abstract void Move(Vector2 target);
}

 

MobData.cs

using UnityEngine;

[CreateAssetMenu(fileName = "MobBaseStat", menuName = "Game Data/Stats")]
public class MobData : ScriptableObject
{
    public float currentHealth;
    public float deffensePower;
}

 

그리고 일단 하나의 Mob을 움직여보기 위해 위의 abstract 클래스를 상속받는 MobController를 만들어주자.

using UnityEditor;
using UnityEngine;

public class MobController : BaseMobController
{
    [SerializeField] private float speed = 10f;
    [SerializeField] private float detectionRange = 5f;
    [SerializeField] private float chasableRange = 5f;
    [SerializeField] private float attackRange = 5f;

    [SerializeField] private Vector2 patrolPointA;
    [SerializeField] private Vector2 patrolPointB;

    private Rigidbody2D Rigidbody2D;
    private Animator animator;

    public override float GetDetectionRange() => detectionRange;
    public override float GetAttackRange() => attackRange;
    public override float GetChasableRange() => chasableRange;

    public override Vector2 GetPatrolPointA() => patrolPointA;
    public override Vector2 GetPatrolPointB() => patrolPointB;

    private void Awake()
    {
        Rigidbody2D = GetComponent<Rigidbody2D>();
        animator = GetComponent<Animator>();

        // 초기 상태를 순찰 상태로 설정
        ChangeState(new PatrolState(patrolPointA, patrolPointB));
    }

    private void FixedUpdate()
    {
        currentState?.Execute();
    }

    public override void ChangeState(IMobState newState)
    {
        currentState?.Exit();
        currentState = newState;
        currentState.Enter(this);
    }


    public override void SetDestination(Vector2 destination)
    {
        Move(destination);
    }

    public override void SetPatrolPoints(Vector2 pointA, Vector2 pointB)
    {
        patrolPointA = pointA;
        patrolPointB = pointB;
#if UNITY_EDITOR
        EditorUtility.SetDirty(this); // 에디터에서 변경사항을 감지하도록 설정
#endif
    }

    public override bool IsPlayerDetected()
    {
        return Vector2.Distance(transform.position, Managers.Game.GetPlayer().transform.position) <= detectionRange;
    }

    public override void Move(Vector2 target)
    {
        //이동
        Vector2 currentPosition = transform.position;
        //방향벡터
        Vector2 direction = (target - currentPosition).normalized;
        //방향*속도 => 해당방향으로의 속도
        Rigidbody2D.velocity = direction * speed;

        animator.SetFloat("MoveX", direction.x);
        animator.SetFloat("MoveY", direction.y);
        animator.SetFloat("Speed",Rigidbody2D.velocity.magnitude);
    }
}

 

이때 움직이는 방향에 따라 다른 애니메이션이 재생되도록 몹의 애니메이션에 Blend Tree를 적용시켜 주었다.

그리고 이 PatrolPoint를 에디터에서 보고 직접 조정할 수 있으며 초기화할 수도 있게하기위해 Editor 수정 코드를 추가해주었다.

MobController.cs

using UnityEditor;
using UnityEngine;

public class MobController : BaseMobController
{
    [SerializeField] private float speed = 10f;
    [SerializeField] private float detectionRange = 5f;
    [SerializeField] private float chasableRange = 5f;
    [SerializeField] private float attackRange = 5f;

    [SerializeField] private Vector2 patrolPointA;
    [SerializeField] private Vector2 patrolPointB;

    private Rigidbody2D Rigidbody2D;
    private Animator animator;

    public override float GetDetectionRange() => detectionRange;
    public override float GetAttackRange() => attackRange;
    public override float GetChasableRange() => chasableRange;

    public override Vector2 GetPatrolPointA() => patrolPointA;
    public override Vector2 GetPatrolPointB() => patrolPointB;

    private void Awake()
    {
        Rigidbody2D = GetComponent<Rigidbody2D>();
        animator = GetComponent<Animator>();

        // 초기 상태를 순찰 상태로 설정
        ChangeState(new PatrolState(patrolPointA, patrolPointB));
    }

    private void FixedUpdate()
    {
        currentState?.Execute();
    }

    public override void ChangeState(IMobState newState)
    {
        currentState?.Exit();
        currentState = newState;
        currentState.Enter(this);
    }


    public override void SetDestination(Vector2 destination)
    {
        Move(destination);
    }

    public override void SetPatrolPoints(Vector2 pointA, Vector2 pointB)
    {
        patrolPointA = pointA;
        patrolPointB = pointB;
#if UNITY_EDITOR
        EditorUtility.SetDirty(this); // 에디터에서 변경사항을 감지하도록 설정
#endif
    }

    public override bool IsPlayerDetected()
    {
        return Vector2.Distance(transform.position, Managers.Game.GetPlayer().transform.position) <= detectionRange;
    }

    public override void Move(Vector2 target)
    {
        //이동
        Vector2 currentPosition = transform.position;
        //방향벡터
        Vector2 direction = (target - currentPosition).normalized;
        //방향*속도 => 해당방향으로의 속도
        Rigidbody2D.velocity = direction * speed;

        animator.SetFloat("MoveX", direction.x);
        animator.SetFloat("MoveY", direction.y);
        animator.SetFloat("Speed",Rigidbody2D.velocity.magnitude);
    }
}

 

 

에디터를 수정해주려면 [CustomEditor(typeof(BaseMobController), true)] 이러한 코드를 클래스전에 선언해주어야한다. 또한 클래스에서 Editor 클래스를  상속받아 코드를 구현해야한다. setDirty를 통해 진행상황이 바로 저장되도록 구현하였다.

MobControllerEditor.cs

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(BaseMobController), true)] // 이제 모든 MobController 파생 클래스에 대해 이 에디터를 사용할 수 있습니다.
public class MobControllerEditor : Editor
{
    private float _handleSize = 5f; // Scene view에서의 핸들 크기를 더 작게 조정

    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI(); // 기존 인스펙터 GUI 요소를 그린다.

        BaseMobController mob = (BaseMobController)target;
        if (GUILayout.Button("Initialize Patrol Points"))
        {
            Vector2 center = mob.transform.position;
            Vector2 pointA = center + new Vector2(2f, 0f);
            Vector2 pointB = center + new Vector2(-2f, 0f);

            mob.SetPatrolPoints(pointA, pointB);

            //변경사항 적용
            EditorUtility.SetDirty(mob);
        }
    }

    protected void OnSceneGUI()
    {
        BaseMobController mob = (BaseMobController)target;
        EditorGUI.BeginChangeCheck();

        Vector3 pointAWorld = mob.GetPatrolPointA();
        Vector3 pointBWorld = mob.GetPatrolPointB();

        // 🔹 Scene 뷰 줌 레벨에 따라 핸들 크기 자동 조정
        float handleSize = HandleUtility.GetHandleSize(mob.transform.position) * 0.2f;

        // 현재 상태에 따라 범위를 다르게 그리기
        IMobState currentState = mob.GetCurrentState();
        if (currentState is PatrolState)
        {
            DrawDetectionRange(mob);
            DrawAttackRange(mob);
        }
        else if (currentState is ChaseState)
        {
            DrawChasableRange(mob);
            DrawAttackRange(mob);
        }
        else if (currentState is AttackState)
        {
            DrawAttackRange(mob);
        }

        Handles.color = Color.red;
        pointAWorld = Handles.FreeMoveHandle(pointAWorld, handleSize, Vector3.zero, Handles.SphereHandleCap);
        Handles.Label(pointAWorld, "Patrol Point A");

        Handles.color = Color.blue;
        pointBWorld = Handles.FreeMoveHandle(pointBWorld, handleSize, Vector3.zero, Handles.SphereHandleCap);
        Handles.Label(pointBWorld, "Patrol Point B");

        if (EditorGUI.EndChangeCheck())
        {
            Undo.RecordObject(mob, "Change Patrol Points");
            mob.SetPatrolPoints(pointAWorld, pointBWorld);
            EditorUtility.SetDirty(mob);
        }

        Handles.DrawLine(pointAWorld, pointBWorld);
    }


    private void DrawDetectionRange(BaseMobController mob)
    {
        Handles.color = Color.yellow;
        Handles.DrawWireArc(mob.transform.position, Vector3.forward, Vector3.up, 360, mob.GetDetectionRange());
    }

    private void DrawChasableRange(BaseMobController mob)
    {
        Handles.color = Color.yellow;
        Handles.DrawWireArc(mob.transform.position, Vector3.forward, Vector3.up, 360, mob.GetChasableRange());
    }

    private void DrawAttackRange(BaseMobController mob)
    {
        Handles.color = Color.red;
        Handles.DrawWireArc(mob.transform.position, Vector3.forward, Vector3.up, 360, mob.GetAttackRange());
    }
}

 

 

이렇게 해주면 Scene창에서 캐릭터의 Patorl Point를 눈으로 보고 직접 수정해줄 수 있으며 초기화해줄 수 있다.

 

이렇게 해주고 실행해주면 Patrol이 잘 실행되는것을 볼 수 있다.


이제 적이 움직일 수 있도록 만들어보자 

이를 위해 Nav Mesh를 사용하자. 이를 위해 액터 배치 패널을 열어주고 navmeshBounds Volume을 선택해주자

선택해주고 맵에 배치해주고 P키를 눌러보면 nav 매쉬가 적용된 부분을 볼 수 있다.

 

이 볼륨이 맵 전체에 적용될 수 있도록 크기를 키워주자.

 

이렇게 해주면 이제 AI 기능을 사용하여 적을 움직이게 할 수 있다. 이 기능을 사용하기 위해 블루프린트 클래스로 이동해주자.

이때 AI 컨트롤러가 적이 Spawn했을 때도 잘 작동하게 하기위해 디테일창에서 AI자동 빙의를 수정해주자

일단 테스트를 위해 시작할 때 AI컨트롤러를 통해 움직이도록 해보자

 

이렇게 해주면 0,0,0으로 이동하는 것을 볼 수 있다.

 

이제 특정 액터로 이동하게 만들기 위해 Actor 타입의 변수를 하나 선언해주자. 이때 기본값을 설정해줄 수도 있는데 이때 변수의 인스턴스 편집가능을 체크해주어야한다.

또한 Target Poin 객체를 추가하는 것으로 움직일 위치를 정해줄 수 있다.

 

움직이는 사용자를 Chase하려면 플레이어가 공격할 때 이 변수를 조정해주고 움직이는 함수를 호출해주는 것을 반복하는것으로 구현할 수 있을 것이다. 

그리고 프로젝트 세팅에서 네비게이션 매쉬의 런타임 생성을 Dynamic으로 바꿔주면 항아리같은 부숴지는 물체나 문 같이 열릴 수 있는 물체의 상태에 따라 이 매쉬가 재구성 된다. 이는 고비용이 든다.

 

또한 셀 사이즈 및 높이를 조정해주는 것으로 매쉬가 어떻게 얼마나 적용될지 정해줄 수 있다.

 

적이 움직일 때 애니메이션을 추가해주자 애니메이션은 Mixamo에서 추가해주자. 이때 Blender를 통해 Root본을 추가해주는 작업을 해주어야한다. 이때 폴더에 하나의 with Skin Animation이 존재해야 다른 것에도 일괄적으로 자동 적용이 된다.

Blender에서 N키를 눌러 사이드 바를 열 수 있다.

이렇게 변환이 끝난 애니메이션을 매쉬없이 임폴트해준다.

'게임공부 > Unreal Engine' 카테고리의 다른 글

[Unreal Engine][C++]25. Patrol  (0) 2025.02.07
[Unreal Engine][C++]24. Enemey Behavior2  (0) 2025.02.05
[Unreal Engine][C++]22. Death  (1) 2025.01.26
[Unreal Engine][C++]21. Damage  (1) 2025.01.21
[Unreal Engine][C++]20. Actor Component  (0) 2025.01.16

+ Recent posts