BulletAnt 개발일지 (10) - Egg 크래시, 시간 동기화, Subsystem 초기화 함수

김펭귄·5일 전

Today What I Learned (TIL)

목록 보기
125/139

Egg 자폭 몬스터의 간헐적 크래시 문제

다시 Egg(자폭병)에서 가끔 GA_Die 실행 중 크래시가 발생했다.

호출 스택을 확인해보니 AbilityTask 내부에서 nullptr 접근이 발생하고 있었고,
먼저 어떤 객체인지 확인하기 위해 디버거 창을 추적했다.

계속 확인해보니 AM_EggDie 관련 객체가 실행 중이라는 것을 발견했다.

그런데 이 시점에서 이상한 점이 있었다.
현재 Egg는 공격 후 자폭하며 바로 Destroy 되는 구조였기 때문에 GA_Die가 실행되는 것 자체가 비정상 상황이었다.

흐름을 다시 분석해보니 원인은 공격 도중 들어오는 추가 데미지였다.
자폭 도중에도 충돌이 살아 있어서 계속 데미지를 받을 수 있었다.

결국:

  • 공격 중 체력이 0이 됨
  • Death 로직 실행
  • 동시에 공격 종료 후 Destroy 실행

상황이 겹쳐버렸다.

즉:

  • 한쪽에서는 죽는 Ability 실행 중
  • 다른 쪽에서는 이미 Destroy 진행

상태였고, 그 결과 AbilityTask가 이미 제거 중인 객체를 참조하면서 nullptr 문제가 발생한 것으로 보였다.

특히 몽타주 재생 후 대기 중인 Task에서 터지는 것을 보고 원인을 확신할 수 있었다.

해결법

결국 해결 방법은 단순했다.

공격이 시작되면 더 이상 어떤 충돌도 발생하지 않게 만든다.

그래서 Egg 공격 몽타주에 AnimNotify를 추가하고,
해당 Notify 시점에서 모든 Collision을 비활성화하도록 수정했다.
이후에는:

  • 공격 중 추가 데미지 미발생
  • Death 로직 중복 실행 방지
  • Destroy 충돌 제거

문제가 재발하지 않는 것을 확인할 수 있었다.

void UAnimNotify_Boom::Notify(/**/)
{
	BaseEnemyCharacter->Multicast_SetNoCollision();
}

Wave 시간 동기화 구조 변경

기존에는 Wave 시간을 SpawnManagerSubsystem에서 직접 관리하고 있었다.
이유는 웨이브를 관리하는 주체가 SpawnManager였기 때문이다.

하지만 이후 남은 시간을 UI로 모든 플레이어에게 보여주려 하면서 문제가 생겼다.

네트워크 동기화가 필요했는데, Subsystem은 기본적으로 Replication 대상이 아니다.

언리얼의 동기화는 NetDriver를 통해 이루어지며, 실제로 Replication이 가능한 대상은:

  • Actor
  • ActorComponent

정도로 제한되기 때문이다.

GameState에서 관리

그래서 시간 데이터는 GameState에서 저장하도록 변경했다.

GameState는:

  • 모든 클라이언트에 동기화시킬 수 있으며
  • 상태를 저장하기 적합하기 때문이다.

다만 책임 자체는 그대로 유지하고 싶었기 때문에 SpawnManager는 시간 관리, GameState는 저장으로 책임을 분리하였다.


SpawnManager 초기화 시점 변경

기존에는 SpawnManagerSubsystem에서 Initialize(), Deinitialize()를 사용해 초기화와 정리를 하였다.

하지만 구조를 다시 보니 시점이 맞지 않았다.

Initialize()는 아직 World의 Framework Actor들이 생성되기 전에 실행된다.

즉:

  • GameState
  • 각종 Actor
  • BeginPlay 로직

등이 아직 준비되지 않았을 가능성이 있었다.

현재 스폰 시스템은 실제 게임이 시작된 이후 동작해야 하는 로직이었기 때문에,
Initialize()보다 OnWorldBeginPlay() 타이밍이 더 적절해서 수정하였다.

이 함수는 World의 BeginPlay 시점에 함께 호출되므로,
필요한 Framework 객체들이 모두 생성된 상태를 보장할 수 있었다.

OnWorldEndplay

정리 로직도 동일한 이유로 수정했다.
기존 Deinitialize()는 Subsystem 파괴 시 호출되는데, 이미 World나 Actor가 제거 중일 수 있었다.
그래서 World 접근 안정성이 보장되지 않았기 때문에 OnWorldBeginPlay로 수정하였다.

void USpawnManagerSubsystem::OnWorldBeginPlay(UWorld& InWorld)
{
	UWorld* World = GetWorld();
	if (World->GetNetMode() == ENetMode::NM_Client)
	{
		return;
	}
	CachedGameState = World->GetGameState<ABAGameState>();
    
	if (CanStartWave())
	{
		PrepareWave();
	}
}

void USpawnManagerSubsystem::OnWorldEndPlay(UWorld& InWorld)
{
	GetWorld()->GetTimerManager().ClearAllTimersForObject(this);
}
profile
반갑습니다

0개의 댓글