BulletAnt 개발일지 (11) - GCN, 스폰위치 안정화

김펭귄·2026년 5월 16일

Today What I Learned (TIL)

목록 보기
126/142

Egg 폭발 이펙트 동기화

처음에는:

“어차피 애님 몽타주는 모든 클라이언트에서 재생되니까,
그냥 Notify에서 각자 파티클 재생하면 되는 거 아닌가?”

라는 생각도 했다.

하지만 이 방식은 문제가 있었다.

  • 네트워크 지연
  • 중간 난입 플레이어

특히 중간에 접속한 플레이어는 이미 Notify 시점이 지나버렸다면 폭발 이펙트를 못 볼 수도 있었다.

반면 GameplayCue는:

  • 서버 기준 연출 관리 가능
  • 네트워크 동기화 지원
  • GAS 흐름과 연결 가능

하다는 장점이 있었다.

결국:

“폭발 여부는 서버가 결정하고,
연출은 GameplayCue로 동기화한다”

는 방향이 더 맞다고 판단했다.

GameplayCue

  • Notify에 GameplayCue Tag 등록
  • Notify 실행 시 Cue 호출
  • GameplayCueNotify_Static_Burst 기반 BP 생성
  • Niagara 등록

구조로 정리했다.

추가로 GameplayCue Tag는 반드시:

GameplayCue.*

형태로 시작해야 정상 인식되기 때문에,
Cue와 Notify 모두 동일 태그를 사용하도록 구성했다.

void UAnimNotify_Boom::Notify(/**/)
{
	FGameplayCueParameters Params;
	Params.Instigator = BaseEnemyCharacter;
	Params.Location = MeshComp->GetSocketLocation(SocketName);
	Params.Normal = MeshComp->GetSocketRotation(SocketName).Vector();

	ASC->ExecuteGameplayCue(GameplayCueBoomTag, Params);
}


적 스폰 위치 탐색

적을 대량으로 스폰하다 보니:

  • 적끼리 겹치거나
  • 건물에 끼거나
  • 땅 아래에 생성되는

문제가 발생했다.

기존에는 단순히 Z값을 위로 올려 스폰했지만, 지형이 복잡해지면서 한계가 있었다.

그래서 스폰 위치를 결정할 때 실제 지형을 먼저 검사하도록 수정했다.
우선 랜덤 XY 위치를 정한 뒤, 위쪽에서 아래로 LineTrace를 발사해 실제 바닥 위치를 찾도록 구성했다.
다만 이 경우 아파트 옥상 같이 높은 곳도 감지될 수 있어서 Trace 결과의 Z값을 Core 기준 높이와 비교해 너무 높은 위치는 제외하도록 처리했다.

이후 실제 스폰 가능 여부는 Capsule 충돌 검사를 사용해

  • 다른 적과 겹치는지
  • 장애물 내부인지

검사했고, Hit이 발생하지 않을 때만 스폰하도록 구성했다.

즉:

  • 랜덤 XY 생성
  • LineTrace로 실제 바닥 위치 보정
  • 최종 안전 위치 반환

역할까지 함께 담당한다.

결과적으로:

단순 랜덤 스폰이 아니라,
실제 지형과 충돌까지 고려한 안전한 스폰 구조

로 개선할 수 있었다.

void USpawnManagerSubsystem::SpawnEnemies()
{
	for (int32 j = 0; j < Count; j++)
	{
		if (CanSpawnEnemy(SpawnLocation))
		{
			ABaseEnemyCharacter* Enemy = GetWorld()->SpawnActor<ABaseEnemyCharacter>(
				EnemyClass,
				SpawnLocation,
				FRotator::ZeroRotator,
				SpawnParams
			);
	// ... //
}

bool USpawnManagerSubsystem::CanSpawnEnemy(FVector& InSpawnLocation)
{
	FHitResult HitResult;
	FCollisionQueryParams QueryParams;
	bool bHit = World->LineTraceSingleByChannel(HitResult, 
    										    TraceStart, 
                                                TraceEnd, 
                                                ECC_WorldStatic, 
                                                QueryParams);

	if (bHit)
	{
		FVector FinalSpawnLocation = HitResult.ImpactPoint;
		if (FinalSpawnLocation.Z > TargetCore->GetActorLocation().Z + 100)
		{
			return false;
		}

		FCollisionShape Capsule = FCollisionShape::MakeCapsule(CapsuleRadius, CapsuleHalfHeight);
		FHitResult SweepHit;
		bool bIsBlocked = World->SweepSingleByChannel(
			SweepHit,
			FinalSpawnLocation + FVector(0, 0, CapsuleHalfHeight + 50), 
			FinalSpawnLocation + FVector(0, 0, CapsuleHalfHeight + 50),
			FQuat::Identity,
			ECC_Pawn, 
			Capsule
		);

		if (!bIsBlocked)
		{
			InSpawnLocation = FinalSpawnLocation + FVector(0, 0, CapsuleHalfHeight);
			return true;
		}
		else
		{
			return false;
		}
	}

	return false;
}
profile
반갑습니다

0개의 댓글