12.10 - TIL

김혁·2025년 12월 10일

TIL

목록 보기
73/84

오늘의 코드카타

  • 사라지는 발판
    • 미니맥스 알고리즘을 활용하여 이기는 경우 최대한 턴을 적게, 지는 경우 최대한 턴을 많이 소모하게 하는 경우를 계산하는 문제
    • a가 먼저 시작하기 때문에 짝수 턴에 종료되면 a가 지고, 홀수 턴에 종료되면 a가 이기게 됨
    • a가 이기는 경우가 있으면, a는 무조건 이길 수 있기 때문에 이기려고 해야 함
    • 위의 케이스를 생각해서 dfs를 통해 이동하는 경우의 수를 전부 계산해서 풀었다.
    • https://school.programmers.co.kr/learn/courses/30/lessons/92345

팀 프로젝트 진행 내용

1. 캐릭터 관전 기능 - 다른 플레이어를 볼 수 있게 구현

  • 기존에는 관전하기 버튼을 누르면 한 명의 플레이어만 관전할 수 있었는데, 이를 버튼을 통해 다른 플레이어를 선택해서 볼 수 있는 기능을 구현하고자 했다.
  • 관전 UI를 만들어서 해당 UI에서 버튼을 통해 관전하는 플레이어를 선택하게끔 구현했다.
  • 기존에는 현재 관전하는 폰만 가지고 있었는데, 다음 플레이어를 찾기 위해 인덱스를 추가적으로 저장했다.
// AO_PlayerController_Stage.h

UCLASS()
class AO_API AAO_PlayerController_Stage : public AAO_PlayerController_InGameBase
{
	GENERATED_BODY()
    
    ... 
    
protected:
    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "AO|UI")
	TSubclassOf<UUserWidget> SpectateWidgetClass;

	UPROPERTY()
	TObjectPtr<UUserWidget> SpectateWidget;
    
public:
	UFUNCTION(BlueprintCallable, Category = "AO|UI")
	void RequestSpectateNext(bool bForward);
    
protected:
    UFUNCTION(Server, Reliable)
	void ServerRPC_RequestSpectateNext(bool bForward);
    
    UFUNCTION(Client, Reliable)
	void ClientRPC_SetSpectateTarget(APawn* NewTarget, int32 NewPlayerIndex);

	UPROPERTY(BlueprintReadWrite)
	TObjectPtr<APawn> CurrentSpectateTarget;

	int32 CurrentSpectatePlayerIndex = INDEX_NONE;

	TObjectPtr<APawn> FindNextSpectateTarget(bool bForward, int32& OutNewIndex);
}
  • 사망 UI에서 사용하는 함수에는 관전용 UI를 출력하고, 사망 UI를 안 보이게끔 하는 로직을 추가했다.
  • 왼쪽, 오른쪽 버튼을 통해 관전하는 플레이어를 변경할 수 있게 하고자, 방향을 정할 수 있는 매개변수를 받아서 사용하고자 했다.
// AO_PlayerController_Stage.cpp

void AAO_PlayerController_Stage::RequestSpectate()
{
	if (IsLocalController())
	{
		ServerRPC_RequestSpectate();

		if (!SpectateWidget && SpectateWidgetClass)
		{
			SpectateWidget = CreateWidget<UUserWidget>(this, SpectateWidgetClass);
		}
	
		if (SpectateWidget)
		{
			SpectateWidget->AddToViewport();
		}

		if (DeathWidget)
		{
			DeathWidget->RemoveFromParent();
		}
	}
}

void AAO_PlayerController_Stage::RequestSpectateNext(bool bForward)
{
	if (IsLocalController())
	{
		ServerRPC_RequestSpectateNext(bForward);
	}
}
  • 서버에서 다음 관전자를 찾아서 클라에서 해당 플레이어를 관전하게끔 하는 구조로 작성을 했다.
  • 추가적으로 관전하는 사람의 이름을 보여주는 것이 좋을 것 같아서 현재 관전하는 플레이어의 이름을 찾아서 관전 UI에 Blueprint를 통해 이름을 설정할 수 있게끔 했다.
// AO_PlayerController_Stage.cpp

void AAO_PlayerController_Stage::ServerRPC_RequestSpectateNext_Implementation(bool bForward)
{
	int32 NewIndex = INDEX_NONE;
	TObjectPtr<APawn> NewTarget = FindNextSpectateTarget(bForward, NewIndex);

	if (NewTarget && NewIndex != INDEX_NONE)
	{
		CurrentSpectateTarget = NewTarget;
		CurrentSpectatePlayerIndex = NewIndex;
		
		ClientRPC_SetSpectateTarget(NewTarget, NewIndex);
	}
}

void AAO_PlayerController_Stage::ClientRPC_SetSpectateTarget_Implementation(APawn* NewTarget, int32 NewPlayerIndex)
{
	if (!IsLocalController() || !NewTarget)
	{
		return;
	}

	CurrentSpectateTarget = NewTarget;
	CurrentSpectatePlayerIndex = NewPlayerIndex;

	const float BlendTime = 0.4f;
	SetViewTargetWithBlend(NewTarget, BlendTime);

	FText SpectatingPlayerName = FText::GetEmpty();
	if (TObjectPtr<APlayerState> PS = NewTarget->GetPlayerState())
	{
		SpectatingPlayerName = FText::FromString(PS->GetPlayerName());
	}
	
	if (SpectateWidget)
	{
		if (TObjectPtr<UAO_SpectateWidget> SpectateUI = Cast<UAO_SpectateWidget>(SpectateWidget))
		{
			SpectateUI->SetSpectatingPlayerName(SpectatingPlayerName);
		}
	}
}
  • 다음 관전자를 찾는 로직은 먼저 살아있는 플레이어를 찾고, 살아있는 플레이어가 없다면 관전을 못 하게 했다.
  • 현재 관전하는 플레이어를 기준으로 앞/뒤 방향으로 살아있는 플레이어를 찾아서 반환하게끔 했다.
TObjectPtr<APawn> AAO_PlayerController_Stage::FindNextSpectateTarget(bool bForward, int32& OutNewIndex)
{
	OutNewIndex = INDEX_NONE;
	
	TObjectPtr<UWorld> World = GetWorld();
	checkf(World, TEXT("World is null"));
	
	TObjectPtr<AGameStateBase> GS = GetWorld()->GetGameState<AGameStateBase>();
	checkf(GS, TEXT("GameState is null"));

	TObjectPtr<AAO_PlayerState> MyPS = GetPlayerState<AAO_PlayerState>();
	checkf(MyPS, TEXT("PlayerState is null"));

	const TArray<TObjectPtr<APlayerState>>& Players = GS->PlayerArray;
	const int32 NumPlayers = Players.Num();
	if (NumPlayers == 0) return nullptr;

	// 살아있는 후보 만들기
	TArray<int32> AliveIndices;
	AliveIndices.Reserve(NumPlayers);
	
	for (int32 i = 0; i < NumPlayers; ++i)
	{
		TObjectPtr<AAO_PlayerState> PS = Cast<AAO_PlayerState>(Players[i]);
		if (!PS || PS == MyPS
			|| !PS->GetIsAlive()
			|| !PS->GetPawn()) continue;

		AliveIndices.Add(i);
	}

	if (AliveIndices.Num() == 0)
	{
		return nullptr;
	}

	// 현재 관전 인덱스 기준으로 다음/이전 찾기
	int32 StartIndexInPlayerArray = INDEX_NONE;

	if (CurrentSpectatePlayerIndex != INDEX_NONE && Players.IsValidIndex(CurrentSpectatePlayerIndex))
	{
		StartIndexInPlayerArray = CurrentSpectatePlayerIndex;
	}
	else
	{
		StartIndexInPlayerArray = AliveIndices[0];
	}

	for (int32 Offset = 1; Offset < NumPlayers; ++Offset)
	{
		int32 RawIndex;
		if (bForward)
		{
			RawIndex = (StartIndexInPlayerArray + Offset) % NumPlayers;
		}
		else
		{
			RawIndex = (StartIndexInPlayerArray - Offset + NumPlayers) % NumPlayers;
		}

		TObjectPtr<AAO_PlayerState> PS = Cast<AAO_PlayerState>(Players[RawIndex]);
		if (!PS || PS == MyPS || !PS->GetIsAlive()) continue;

		TObjectPtr<APawn> OtherPawn = PS->GetPawn();
		if (!OtherPawn) continue;

		OutNewIndex = RawIndex;
		return OtherPawn;
	}

	return nullptr;
}
  • 관전 UI의 블루프린트를 보자면, 아래와 같이 버튼을 통해 다음/이전 관전자를 볼 수 있게 설정했고, Text Block에 캐릭터의 이름이 출력되게 연결을 했다.


2. 캐릭터 관전 기능 - 플레이하는 시점으로 변경

  • 기본적으로 SetViewTarget()을 통해 관전을 하는 경우, Pawn은 내부에 PawnCameraManager가 지정한 카메라를 통해 관전을 하게 된다.
  • 기획적으로 다른 플레이어가 플레이하는 카메라를 통해 관전을 시키는 게 낫다는 결론이 나와서 해당 카메라가 아닌 캐릭터에 붙어있는 카메라를 통해 관전 시점을 변경하고자 했다.
  • Pawn에서 CalcCamera() 함수를 통해 관전하는 카메라를 지정할 수 있었다.
// AO_PlayerCharacter.h

virtual void CalcCamera(float DeltaTime, struct FMinimalViewInfo& OutResult) override;


// AO_PlayerCharacter.cpp

void AAO_PlayerCharacter::CalcCamera(float DeltaTime, struct FMinimalViewInfo& OutResult)
{
	if (Camera)
	{
		Camera->GetCameraView(DeltaTime, OutResult);
	}
	else
	{
		Super::CalcCamera(DeltaTime, OutResult);
	}
}

3. 관전용 UI 제작

  • 관전하는 UI에서 기존의 HUD를 제거하지 않고, 그대로 사용을 했는데, 관전을 하는 사람의 인벤토리나 체력, 스태미나가 보이는 것이 자연스러울 것으로 보여서 관전용 UI를 제작하고자 했다.
  • 기존의 HUD와 동일하게 배치되고, 위에서 작성한 다른 플레이어를 관전할 수 있는 버튼을 추가해서 아래와 같이 UI를 제작했다.
  • 체력과 스태미나 위젯은 캐릭터의 Attribute Set의 체력과 스태미나와 바인딩해서 화면에 보여주고 있다.
  • 이를 위해 기존의 체력, 스태미나 위젯의 바인딩을 변경할 수 있게 수정했다.
// AO_StaminaWidget.h

UCLASS()
class AO_API UAO_StaminaWidget : public UAO_UserWidget
{
	GENERATED_BODY()

public:
	UFUNCTION(BlueprintCallable, Category = "GAS|UI")
	void BindToASC(UAbilitySystemComponent* InASC);

	UFUNCTION(BlueprintCallable, Category = "GAS|UI")
	void BindToCharacter(ACharacter* InCharacter);
    
    ...
    
protected:
	void UnbindStaminaDelegates();
    
	UPROPERTY()
	TObjectPtr<UAbilitySystemComponent> BoundASC;

	FDelegateHandle StaminaChangedHandle;
	FDelegateHandle MaxStaminaChangedHandle;
};
  • 외부에서는 BindToCharacter()를 통해 캐릭터에서 바인딩을 하거나, BindToASC()를 통해 ASC를 통해 바인딩할 수 있게끔 구현했다.
  • 바인딩하기 전에 기존의 델리게이트와 연결되어 있는 부분은 언바인딩을 통해 해제해줬다.
// AO_StaminaWidget.cpp

void UAO_StaminaWidget::BindToASC(UAbilitySystemComponent* InASC)
{
	if (BoundASC == InASC)
	{
		return;
	}

	UnbindStaminaDelegates();

	BoundASC = InASC;
	if (BoundASC)
	{
		BindStaminaDelegates(BoundASC);
	}
}

void UAO_StaminaWidget::BindToCharacter(ACharacter* InCharacter)
{
	if (!InCharacter)
	{
		BindToASC(nullptr);
		return;
	}

	if (TObjectPtr<UAbilitySystemComponent> ASC = InCharacter->FindComponentByClass<UAbilitySystemComponent>())
	{
		BindToASC(ASC);
	}
	else
	{
		BindToASC(nullptr);
	}
}
  • 기존에 바인딩했을 시에 저장해놓은 핸들을 통해 AttributeSet의 값과 언바인딩을 했다.
// AO_StaminaWidget.cpp

void UAO_StaminaWidget::UnbindStaminaDelegates()
{
	if (!BoundASC)
	{
		return;
	}

	if (StaminaChangedHandle.IsValid())
	{
		BoundASC->GetGameplayAttributeValueChangeDelegate(
			UAO_PlayerCharacter_AttributeSet::GetStaminaAttribute())
			.Remove(StaminaChangedHandle);
		StaminaChangedHandle.Reset();
	}

	if (MaxStaminaChangedHandle.IsValid())
	{
		BoundASC->GetGameplayAttributeValueChangeDelegate(
			UAO_PlayerCharacter_AttributeSet::GetMaxStaminaAttribute())
			.Remove(MaxStaminaChangedHandle);
		MaxStaminaChangedHandle.Reset();
	}

	BoundASC = nullptr;
}
  • 체력과 스태미나 위젯의 경우, 위와 같은 로직을 통해 바인딩을 진행했다.
  • 관전 위젯에 관전하는 플레이어를 변경할 시에 부르는 함수를 선언해서 플레이어의 이름 및 다른 위젯의 바인딩을 제어했다.
// AO_SpectateWidget.cpp

void UAO_SpectateWidget::SetSpectatingCharacter(ACharacter* InCharacter)
{
	checkf(InCharacter, TEXT("InCharacter is nullptr"));
	SpectatingCharacter = InCharacter;

	// 관전하는 플레이어 이름 설정
	FText SpectatingPlayerName = FText::GetEmpty();
	if (TObjectPtr<APlayerState> PS = SpectatingCharacter->GetPlayerState())
	{
		SpectatingPlayerName = FText::FromString(PS->GetPlayerName());
	}

	SetSpectatingPlayerName(SpectatingPlayerName);

	// 체력 위젯 바인딩
	if (HealthWidget)
	{
		HealthWidget->BindToCharacter(InCharacter);
	}
	
	// 스태미나 위젯 바인딩
	if (StaminaWidget)
	{
		StaminaWidget->BindToCharacter(InCharacter);
	}
}
  • 열차 게이지와 인벤토리의 경우에는 내가 작성을 하지 않아서 HUD에 작성되어있던 로직 그대로 작성을 했다.
  • 해당 로직은 블루프린트에서 델리게이트를 연결해주는 방식으로 동작하는 것을 확인해서 먼저 블루프린트에서 동작할 함수를 만들어서 SetSpectatingCharacter() 함수에서 동작하게끔 하고자 했다.
  • 바인딩을 할 용도로 AfterSpectatingCharacterChanged()를 선언했고, 바인딩을 제거할 용도로 BeforeSpectatingCharacterChanged()를 선언했다.
// AO_SpectateWidget.h

UCLASS()
class AO_API UAO_SpectateWidget : public UAO_UserWidget
{
	...
    
	UFUNCTION(BlueprintImplementableEvent, BlueprintCallable)
	void BeforeSpectatingCharacterChanged(ACharacter* InCharacter);
	UFUNCTION(BlueprintImplementableEvent, BlueprintCallable)
	void AfterSpectatingCharacterChanged(ACharacter* InCharacter);
}

// AO_SpectateWidget.cpp

void UAO_SpectateWidget::SetSpectatingCharacter(ACharacter* InCharacter)
{
	BeforeSpectatingCharacterChanged(SpectatingCharacter);
    
    ...

	AfterSpectatingCharacterChanged(SpectatingCharacter);
}
  • 열차 게이지의 경우에는 관전자랑 관계없이 모두가 동일하기 때문에 위젯이 생성될 때 바인딩해줬다.
  • C++에서 선언한 함수를 통해 해당 Event 발생 시에 아이템 정보를 업데이트해주는 델리게이트를 바인딩 및 언바인딩했다.
  • 현재 로직에는 관전하는 플레이어를 변경하는 시점에서 아이템 정보를 업데이트해주는 로직이 작성되어있지 않다. 따라서 플레이어의 인벤토리가 변경되지 않으면 반영되지 않는다. 이는 추후에 관련 데이터를 불러오는 로직을 추가할 예정이다.

4. 낮은 구간에서 파쿠르 시 오류 수정

  • 아래와 같은 파쿠르 동작이 있을 때, 캐릭터가 공중을 짚고 올라가려고 하는 오류가 발생을 했다.
  • 가장 먼저 손을 바닥에 정확하게 매칭하기 위해서 모션 워핑을 실시하는데, 해당 동작에서 오류가 발생하는 것으로 추측을 했다.
  • 기존의 애니메이션은 1m 정도의 높이를 기준으로 만들어진 것으로 예상이 된다. 따라서 50cm 높이의 장애물을 건너려고 한다면 모션 워핑의 시간이 상당히 필요할 것으로 보인다.
  • 이를 예측하고 노티파이를 확인해보니 너무 짧은 프레임동안 모션 워핑을 진행해서 특정 높이까지만 맞춰주는 방식으로 동작했던 것으로 보인다.
  • 따라서 기존의 약 7프레임정도만 진행됐던 모션워핑을 약 15프레임동안 진행되게 변경했다.
  • 아래처럼 수정을 하니 모션워핑이 정상적으로 동작을 해서 낮은 경우에도 잘 맞게 진행되었다.


5. 벽 근처에서 파쿠르 시 벽을 뚫는 오류 수정

  • 캐릭터가 벽 근처에서 파쿠르를 시도하거나 장애물을 뛰어넘는 동작에서 벽을 뚫고 뛰다가 갑자기 뒤로 텔레포트해서 잡고자 하는 지점을 잡는 경우가 생겼다.
  • 이는 전에부터 간헐적으로 발생한 문제인데, 해당 문제를 현재 수정하고자 했다.

  • 나는 파쿠르하는 몽타주에 Motion Matched Branch In이라는 노티파이 스테이트가 모션 매칭을 할 때, 들어가는 시점을 지정해주는 노티파이로 인식하고 있었다. 정확히 말하자면, 몽타주를 재생할 때 자동으로 해당 지점 중에 가장 궤적 정보가 일치하는 시점을 기반으로 재생시켜주는 것으로 알고 있었다.
  • 이 이해도 어느정도 맞는 얘기지만, MotionMatch()를 실행하게 되면, Motion Matched Branch In에서의 비용을 계산해서 가장 알맞은 시점을 반환해주고, PlayMontage()를 통해 시작 시점을 지정해서 재생하는 방식이었다.
  • 인식의 오해는 해당 노티파이 구간에서 가장 알맞은 모션부터 시작하게 하는 것은 맞지만, 모션 매칭의 비용을 계산하는 구간인지는 몰랐다는 것이다. 따라서 해당 노티파이 구간을 너무 짧게 잡으면 구간 내에서 비용을 계산할 때, 비용들이 너무 크게 나오면 처음부터 시작하게 되는 현상이 발생했다. 따라서 의도한 곳에서 재생되는 것이 아니라, 처음부터 실행되서 오류가 발생하는 것이었다.
  • 기존의 약 3프레임 정도만 진행했던 부분을 약 6프레임 정도로 늘려주었고, 해당 동작 과정은 정상적으로 발을 착지하는 과정이 있어서 정상적으로 동작할 것으로 생각했다.
  • 추가적으로 Traversal을 검색하는 Schema에서도 0.4초 전의 위치 정보를 통해 궤적 계산을 하는데, 해당 시간이 너무 이른 것으로 보여서, 0.2초 전의 위치 정보도 추가적으로 추가하여 파쿠르 직전의 궤적 정보를 안전하게 사용하고자 했다.
  • 테스트를 해보면 벽이 어느정도 뚫리기는 하는데, 이 정도는 붙어있을 때 감안할 수 있지 않을까하는 생각으로 넘어가고자 했다.
  • 추후에 벽이 뚫리면 카메라가 같이 들어가서 벽에 막혀 검게 보이는 현상이 있는데, 이도 수정할 예정이다.

오늘의 CS

고아 프로세스 (Orphan Process)

  • 자식 프로세스가 종료되지 않았는데, 부모 프로세스가 먼저 종료된 상태의 자식 프로세스

  1. 발생 과정
    (1) 부모-자식 생성 : 부모 프로세스가 시스템 호출을 통해 자식 프로세스를 생성하고 실행시킴
    (2) 부모 종료 : 자식 프로세스가 아직 실행 중인데, 부모 프로세스가 먼저 종료됨
    (3) 고아 상태 : 이 자식 프로세스는 부모가 없으므로 고아 상태가 됨
  2. OS 처리
    • 입양 : OS는 시스템의 최상위 프로세스인 init(또는 ststemd) 프로세스를 고아 프로세스의 새로운 부모로 지정
    • 역할 : init 프로세스는 모든 고아 프로세스가 최종적으로 종료될 때까지 기다렸다가, 해당 자식 프로세스의 종료 상태를 회수하는 wait() 시스템 호출을 수행하여 시스템에서 완전히 제거함
  • 결론 : 고아 프로세스는 불안정한 상태에 놓이지만 최상위 프로세스에 의해 안전하게 관리되며 시스템에 문제를 일으키지 않음

좀비 프로세스 (Zombie Process)

  • 이미 실행을 모두 마치고 종료되었으나 부모 프로세스가 그 종료 상태를 회수(Wait)하지 않아 프로세스 테이블에 남아있는 상태의 프로세스

  1. 발생 과정
    (1) 자식 종료 : 자식 프로세스가 작업을 완료하고 exit() 시스템 호출을 통해 종료됨
    (2) 좀비 상태 진입 : 자식 프로세스의 모든 자원(메모리, 파일 핸들)은 운영체제에 반환되지만, 프로세스 테이블에는 종료 상태 정보만 남긴 채 프로세스가 좀비 상태가 됨
    (3) 부모의 미회수 : 부모 프로세스가 자식의 종료 상태를 확인하고 회수하기 위해 wait() 시스템 호출을 수행해야 하는데, 이를 수행하지 않음
  2. 문제점
    • 자원 점유 : 좀비 프로세스는 메모리나 CPU 시간을 더 이상 사용하지는 않음
    • 프로세스 테이블 점유 : 문제는 프로세스 테이블 엔트리를 계속 점유하고 있음. 좀비 프로세스가 많아지면 새로운 프로세스를 생성할 공간이 부족해짐
  3. 해결 방법
    (1) 부모의 wait() 호출 : 부모 프로세스가 wait() 함수를 호출하여 좀비 프로세스의 종료 상태를 회수하면 즉시 시스템에서 제거됨
    (2) 부모 종료 : 만약 부모 프로세스가 먼저 종료되면, 좀비 프로세스는 고아 프로세스가 되고, init 프로세스에게 입양됨. init 프로세스는 입양된 자식 프로세스가 좀비 상태인 것을 확인하면 즉시 wait()을 호출하여 시스템에서 제거함
  • 결론 : 좀비 프로세스는 부모가 wait()을 호출하지 않은 프로그래밍 오류로 인해 발생함. 이는 프로세스 테이블 엔트리를 점유하여 새 프로세스 생성을 방해할 수 있음
profile
게임 개발자를 향해..

0개의 댓글