no image
[트러블슈팅] 에디터 초기 실행 시 BehaviorTree 활성화 안되는 문제
문제 상황에디터를 처음 켰을 때, 적 AI가 캐릭터를 추격하지 않는 상황이 발생했다. 해결 방법UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Boss|AI")TSoftObjectPtr BehaviorTree;UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Boss|AI")TObjectPtr BehaviorTree;소프트레퍼런스로 BT를 저장하고 있었는데 하드 포인터로 변경하니 잘 활성화가 되었다. 해결 과정뭔가 이상해서 이것저것 만져보다가 BT 를 한 번 수동으로 켜주면 그 때부터는 잘 활성화되는 것을 발견했다.뭔가 C++로 저장하는 부분에서 제대로 메모리에 로드가 안되고 있나? 라는 생각에 BT 저..
2026.03.05
no image
[트러블슈팅] 보스 애니메이션 연결할 때 엔진 튕김 에러
문제 상황애니메이션 연결 과정 중이었는데 실행하면 자꾸 엔진 터짐.. 현재 실행 흐름 정리트러블 슈팅 정리를 위해 간략하게 구조와 흐름을 설명하자면,이 함수는 보스 캐릭터가 스폰될 때, BeginPlay()에서 호출되는 함수이다.GAS를 사용하고 있어서 각 보스는 고유한 ID 값을 가지고 있고, 모듈화를 위해 각 ID 별 매칭된 DataAsset을 가지고 있다.이 DataAsset에서는 각 보스별 ID 값과, 사용할 스켈레탈 메시, 애니메이션 블루프린트, BT, Ability 등을 가지고 있다.나는 이 과정에서 ApplyBossPrimaryData()에서 이 데이터 에셋을 가져와서 보스의 스켈레탈 메시와 애니메이션 블루프린트를 연결해주고자 하였다. 관련 소스 코드void ABaseBossCharacte..
2026.03.03
no image
[UE5, C++] 계절보스 AI 개발 과정 총정리 1편 - Behavior Tree 고도화
총정리 글을 쓰게된 이유프로젝트를 본격적으로 마무리하고 유저테스트와 스팀 출시를 위해 폴리싱을 진행하려고 한다. 지금까지 보스 AI를 만들면서 총 2번의 고도화 작업이 있었다. 당시엔 기획이 자주 변경되는 상황이었기에 BT도 유동적으로 설계해서 2차 설계가 유용했다.근데 지금은? 기획은 정해졌고, 프로젝트를 폴리싱하는 단계이기 때문에 유지보수나 확장성에 용이해야한다. AI를 계속 공부하다보니까 게임 플레이 태그를 활용해서 스킬 선택 처리를 해줄 수도 있을 것 같아서이 부분을 중점으로 적용해나갈 예정이다. 그래서 3차 설계 수정 전에 BT를 설계하면서 내가 고민해왔던 내용들이나 어떻게 발전해왔는지 정리하며, 어떻게 수정하면 좋을지 고민해보려고 한다. BT 고도화가 필요하다고 느꼈던 순간계절보스 BT는..
2025.09.29
no image
[ UE ] BehaviorTree Task 관리
저번 프로젝트에서 적 AI 구현을 담당했었다.그때는 AI를 처음 접하기도 하고, 어려웠어서 Task를 블루프린트로만 구현해서 관리했었다.이번에 C++로도 Task 관리를 할 수 있는 것을 배우게 되어 두 구현 기법의 장단점을 정리해보려고 한다. 블루프린트로 구현했을 때- 노드로 구성되어있어 실행 비용이 큼- Task 호출이 빈번하거나 반복 호출이 많으면 프레임 드랍 가능성 많음- 시각적이라 트리 흐름을 빠르게 파악 가능- Task 로직이 복잡하고 입력인자가 많으면 노드 내부 파악하기 쉽지않음- 디버깅하기 힘들고, 대형 프로젝트가 되면 담당자를 찾기 힘들고, 수정에 버거움- Git 머지 충돌 위험이 높음(BP 파일 바이너리 저장 때문)- 프로토타입으로 설계하기 매우 빠름 C++로 구현했을 때..
2025.04.28
no image
[ C++ ] 프로그래머스 비밀지도
문제 링크https://school.programmers.co.kr/learn/courses/30/lessons/17681 프로그래머스SW개발자를 위한 평가, 교육, 채용까지 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프programmers.co.kr 문제 나의 풀이1. 각 배열이 들어가있는 10진수를 2진수로 변환2 . 2진수로 변환된 배열의 값들을 순회하면서 비교, 두 배열 중 하나라도 1이 있다면 temp에 1로 저장3. temp 순회하면서 1이면 #, 0이면 공백을 answer에 추가+ 처음엔 배열에 들어가있는 수를 하나하나 이진수로 바꾸는 작업을 했었는데 하다보니 코드가 길어지기도 하고, 가독성이 너무 떨어졌다. 바꾸는 과정에서 함수를 만들고 이 함수는 vector를 리턴하..
2025.04.25
C++
no image
[ UE, C++ ] 네트워크 환경에서 클라이언트 카메라 전환시키기
최근 멀티플레이어 격투 게임 프로젝트에서 인게임 시스템을 담당하며, 플레이어 사망과 리스폰 처리에 대한 고민을 했다.이 글에서는 내가 선택한 두 가지 방법과 그 근거, 트러블 슈팅까지 공유하려고 한다. 1. 리스폰 시스템 설계 방향 고민고민 내용 : 즉시 리스폰 vs 대기 후 리스폰최종 결정: 대기 후 리스폰 여러 옵션을 검토한 결과, 라운드 종료 후 일괄 부활 방식을 선택했다.선택 이유게임 밸런스 관점의미 있는 사망 페널티 : 즉시 부활 시 사망의 중요도가 떨어져 무모한 플레이를 유도할 가능성전략적 깊이 향상 : 생존의 가치가 높아지면서 플레이어들이 더 신중한 판단을 내리도록 유도 게임 플로우 관점명확한 라운드 구조 : 각 라운드가 독립적인 완결성을 가지며 게임 리듬감이 향상관전 몰입도 : 사망 후에도..
2025.04.21
no image
[ UE ] TrainingMode 및 UI 구현
훈련장 모드에서 타이머를 구현하다가 발생한 트러블상황과 구조 대공사를 통해 알게된 내용을 정리해보려고 한다. 트러블 슈팅 트러블 상황RoundTime을 0으로 초기화해줬는데 갑자기 이상한 값이 뜸.. 해결방법기존 코드에서 Time이 0일때도 1초씩 감소하는 로직을 타서 발생했던 현상.Time이 아직 초기화되지않았을 때에는 1초씩 감소하지 않도록 방어 코드를 짜주어서 해결. 프로젝트 진행 중 다시 알게된 내용GameMode, GameState, Widget 간의 데이터 처리게임 프레임워크를 설계하고 구현하다보니 구조적인 설계에 대해 많이 고민하고 성장하게 되었다.GameMode, GameState, Widget 간의 데이터 처리에 부분에서 흐름이 어떻게 흘러가는지 이해하게되었다.나는 Game..
2025.04.08
no image
[ UE ] 주말 작업동안의 트러블 슈팅 정리
첫번째 트러블 상황BaseInGameHUD에서 정의한 GetTogglePauseWidget을 PlayerController에서 가져오기 위해 IsValid로 값체크를 하는 과정에서 발생.   이유IsValid()는 UObject* 타입을 요구하는데 GetTogglePauseWidget()은 const 포인터를 반환하고 있음. IsValid()는 주로 non-const 버전을 검사하게 구현되어있어서 생기는 문제.   해결방법 UFUNCTION(BlueprintCallable, Category = "CCFF|UI") FORCEINLINE UTogglePauseWidget* GetTogglePauseWidget() const { return TogglePauseWidget; }IsValid()를 쓰지않고 ≠n..
2025.04.07
no image
[ UE ] 위젯과 타이머 연동하기
위젯에 텍스트로 타이머 기능을 넣는 부분을 진행했다. BaseHUD, BaseWidget 설계위젯을 쓰려고하니 모드마다 공통으로 들어가는 위젯들이 있어서 Base 위젯을 만들면 좋겠다고 생각했다.베이스 HUD와 Widget의 구조를 잡는데 오늘 대부분의 시간을 썼었던 것 같다.HUD와 Widget의 역할을 잘 몰랐었는데 HUD는 UI를 담는 컨테이너 같은 역할이고 그 안에 실질적으로 유저가 보는 것들이 UI라고 이해했다. 그래서 일단 BaseHUD를 설계했고, 공통된 부분만 묶어서 정의해주었다.새로운 HUD가 추가될 때마다 이 BaseHUD를 상속받아서 적용할 수 있게 설계했고, 위젯도 비슷한 방식을 적용했다.근데 위젯은 어떤걸 묶을지 아직 감이 안잡히는 것 같다.. Base가 굳이 필요한가 싶기도하고...
2025.04.04

문제 상황

에디터를 처음 켰을 때, 적 AI가 캐릭터를 추격하지 않는 상황이 발생했다.

 

해결 방법

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Boss|AI")
TSoftObjectPtr<class UBehaviorTree> BehaviorTree;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Boss|AI")
TObjectPtr<UBehaviorTree> BehaviorTree;

소프트레퍼런스로 BT를 저장하고 있었는데 하드 포인터로 변경하니 잘 활성화가 되었다.

 

해결 과정

뭔가 이상해서 이것저것 만져보다가 BT 를 한 번 수동으로 켜주면 그 때부터는 잘 활성화되는 것을 발견했다.

뭔가 C++로 저장하는 부분에서 제대로 메모리에 로드가 안되고 있나? 라는 생각에 BT 저장하는 부분 코드를 다시 살펴봤다.

 

원래는 BT를 소프트레퍼런스로 저장하고 있었는데 이 부분에서 오류가 있다는 것을 알아챘다.

 

소프트 레퍼런스는 실제 에셋 데이터를 메모리에 들고 있는게 아니라 에셋이 어디에 있는지 경로만 저장하고 있다.

그래서 에디터를 처음 실행했을 때 경로만 저장하고 실제 데이터를 올리지 않아서 활성화가 안되는 상태였다.

 

BT 창을 열었을 때는 잘 실행됐던 이유는 내가 이 BT를 여는 순간 백그라운드에서 해당 에셋을 강제로 메모리에 로드해준다.

그래서 이미 BT가 메모리에 등록되어있는 상태에서 소프트 레퍼런스가 메모리에 있는 에셋을 찾아서 정상적으로 작동했던 것이다..

 

BT 같은 경우는 가벼워서 하드포인터로 저장해줘도 성능에 거의 지장이 없다고 한다.. 

문제 상황

애니메이션 연결 과정 중이었는데 실행하면 자꾸 엔진 터짐..

 

현재 실행 흐름 정리

트러블 슈팅 정리를 위해 간략하게 구조와 흐름을 설명하자면,

이 함수는 보스 캐릭터가 스폰될 때, BeginPlay()에서 호출되는 함수이다.

GAS를 사용하고 있어서 각 보스는 고유한 ID 값을 가지고 있고, 모듈화를 위해 각 ID 별 매칭된 DataAsset을 가지고 있다.

이 DataAsset에서는 각 보스별 ID 값과, 사용할 스켈레탈 메시, 애니메이션 블루프린트, BT, Ability 등을 가지고 있다.

나는 이 과정에서  ApplyBossPrimaryData()에서 이 데이터 에셋을 가져와서 보스의 스켈레탈 메시와 애니메이션 블루프린트를 연결해주고자 하였다.

 

관련 소스 코드

void ABaseBossCharacter::ApplyBossPrimaryData()
{
    if (!IsValid(LoadedBossData)) return;
    
    USkeletalMeshComponent* BossMesh = GetMesh();
    if (!IsValid(BossMesh)) return;
    
    USkeletalMesh* LoadedMesh = LoadedBossData->BossMesh.LoadSynchronous();
    if (IsValid(LoadedMesh))
    {
       BossMesh->SetSkeletalMesh(LoadedMesh);
    }
    else
    {
       LD_LOG(Log, TEXT("보스 메시 없음"));
       return;
    }
    
    if (BossMesh->GetSkeletalMeshAsset() != nullptr && !LoadedBossData->BossAnimBlueprint.IsNull())
    {
       if (UClass* AnimClass = LoadedBossData->BossAnimBlueprint.LoadSynchronous())
       {
          BossMesh->SetAnimInstanceClass(AnimClass);
          LD_LOG(Log, TEXT("ApplyBossData: AnimClass applied after Mesh validation."));
       }
    }
}

 

 

문제 상황에서의 애니메이션 블루프린트 노드 

그리고 보스의 애니메이션 블루프린트의 이벤트 창에서 다음과 같이 노드를 배치하였다.

 

이렇게 연결해주고 실행했더니

BossMesh->SetAnimInstanceClass(AnimClass);

이 줄에서 자꾸만 엔진이 튕기는 현상이 발생했다..

 

해결 과정

 코드 상에서는 전혀 문제가 될 부분이 보이지 않아서 애니메이션 블루프린트의 노드들을 하나씩 디버깅해봤다..

근데 저 Has Matching Gameplay Tag 노드에서 터지는걸 발견..

여기서 뭔가 저 Has Matching Gameplay Tag 노드의 Target 핀의 타입이 좀 이상하다는걸 느꼈다.

BossCharacter가 AbilitySystem Component를 가지고 있는데 왜 또 굳이 저 상아색? 노란색 핀으로 타입이 바뀌는지?

그래서 다시 Has Matching Gameplay Tag 노드를 검색해보았는데 이렇게 입력, 출력핀이 달려있는 다른 노드를 찾았다..
얘로 넣어주니 정상적으로 잘 작동한다.. 휴..

 

최종 애니메이션 블루프린트(문제해결)

최종 애니메이션 노드는 위의 이미지와 같다..

 

배운 내용 총정리

이 두 노드 절대 혼동하지 말 것.

 

찾아보니 두 노드의 차이점은 아래와 같다

 

위의 노드는 순수 노드라고 한다. 실행선이 없고, 이 노드는 뒤에 연결된 노드가 결과값을 요구할 때마다 매번 실시간으로 계산을 수행한다.

아래의 노드는 비순수 노드라고 한다. 실행선이 연결되어야 작동하고, 실행선이 노드를 통과하는 순간 딱 한 번만 계산을 수행한다. 특정 이벤트가 일어날 때 순차적으로 태그 검사해야하는 상황에서 사용한다.

 

그리고 아래 노드의 편지봉투 같은 마크는 인터페이스 메세지를 뜻한다.

대상이 태그 인터페이스를 가지고 있는지 확실하지 않은 액터나 컴포넌트를 연결해도, 엔진에서 태그 인터페이스가 있는지 확인한다음에 실행한다는 뜻이다. (안전성 측면에서 유용한듯 싶다) 

 

위의 노드는 인터페이스 자체를 의미한다. 그래서 엔진이 따로 확인하는 과정이 없어서 연결하려는 대상이 확실하게 인터페이스를 가지고 있어야하거나 형변환이 된 상태에서만 사용해야한다고 한다.

총정리 글을 쓰게된 이유


프로젝트를 본격적으로 마무리하고 유저테스트와 스팀 출시를 위해 폴리싱을 진행하려고 한다.

 

지금까지 보스 AI를 만들면서 총 2번의 고도화 작업이 있었다.

 

당시엔 기획이 자주 변경되는 상황이었기에 BT도 유동적으로 설계해서 2차 설계가 유용했다.

근데 지금은? 기획은 정해졌고, 프로젝트를 폴리싱하는 단계이기 때문에 유지보수나 확장성에 용이해야한다.

 

 

 

AI를 계속 공부하다보니까 게임 플레이 태그를 활용해서 스킬 선택 처리를 해줄 수도 있을 것 같아서

이 부분을 중점으로 적용해나갈 예정이다.

 

그래서 3차 설계 수정 전에 BT를 설계하면서 내가 고민해왔던 내용들이나 어떻게 발전해왔는지 정리하며, 어떻게 수정하면 좋을지 고민해보려고 한다.

 

 

BT 고도화가 필요하다고 느꼈던 순간


계절보스 BT는 총 2단계의 BT로 고도화해왔다.

 

이건 고도화 전 초기 설계 단계인 겨울 계절 보스 BT이다.

겨울 보스의 큰 행동 분기는 2개였다.

 

 

Perception 범위 내에 플레이어가 있냐없냐에 따라 집을 공격하거나 플레이어를 공격대상으로 설정했다.

 

 

플레이어쪽으로 이동 -> Enum 클래스에 넣어둔 스킬 인덱스를 랜덤으로 리턴 -> 해당 인덱스의 스킬 몽타주 실행의 단계로 설계했다.

 

 

위의 방법으로 설계하니까 한계가 있었는데,

1. 기획에서 이 스킬을 쓸 때는 후속 스킬이 필요하다는 내용 또는 스킬 중단 같은 디테일한 행동 로직을 제어할 수 없음

2. BT가 단순하다보니 보스가 아니라 스킬만 화려한 일반 엘리트 몬스터 같음 (지능이 부족해보였다)

 

 

=> 행동에 대한 분기를 고도화하고 스킬 선택을 BT에서 하자!라고 생각했다.

(실제로 BT에서 다 처리했더니 디버깅이나 유지보수하는데 굉장히 편했고, 한 눈에 들어왔다)

 

 

고도화한 겨울 계절보스 BT


이건 마무리 단계에서 고도화했던 BT이다.

 

 

고도화하면서 가장 고려했던 내용은

 

1. Behavior Tree에서 한 눈에 보스의 행동 로직 흐름을 이해할 수 있게 설계하는 것

 

2. 스킬 콤보 수정이 용이할 것 (당시 기획이 유동적으로 변경되던 상황)

 

3. 보스답게 행동을 세부적으로 제어가능하도록 최대한 분할하고 독립적으로 짤 것

 

이 3가지 내용을 가장 중요하게 생각하고 설계했다.

 

 

 

 

그리고 비티 고도화를 하면서 더 보완되면 좋을 것 같은 내용을 추가했는데, 다음 4가지였다.

 

1. 체력 기반 페이즈 분기(노말, 광폭화)

=> 기존 보스는 전투할 때 체력이나 다른 요인이 설계되어있지 않았다.

=> 체력별 페이즈를 나눠 광폭화 상태에는 까다로운 스킬이나 강화된 스킬을 시전하게 설계했다.

=>  광폭 상태로 돌입, 광폭 스킬 등에 이펙트와 시각적인 효과를 부여했다. 이로 인해 보스 체력 UI가 없는 상태를 보완할 수 있었다.(광폭 이펙트, 광폭 스킬 등)
 

 

 


2. 콤보 기반 스킬 패턴화(일반 패턴, 억까 패턴)

=> 콤보 안에는 각 스킬을 하드 패턴으로 구성하였으나, 콤보 시퀀스 자체는 랜덤으로 선택되도록 설계했다.

=> 플레이어가 전투를 예측할 수 없고, 피지컬에 의해서 전투할 수 있게 끔 전투에 몰입할 수 있는 환경을 부여했다.

 

 

 

3. 스킬 단위의 태스크 분할

=> 기존에는 C++ 태스크에서 랜덤 인덱스를 반환했으나, 디버깅과 유지보수를 위해 BT에서 선택할 수 있게 수정했다.

=> 더 세부적인 행동 흐름 제어가 가능했고, 기획이 변경되었을 때 비티에서 바로 수정할 수 있는 장점을 살리고자 해당 방식으로 설계했다.

 

 

 


4. EQS 도입한 동적 거리 조절

=> 기존에는 보스가 플레이어에 의존적이라 수동적으로 느껴져 전투에 흥미가 쉽게 떨어질 수 있는 요인이 존재했다.

=> EQS를 도입해서 보스가 스스로 동적으로 거리를 조절할 수 있게 설계했고,

여러 이동기 스킬(추격, 도주, 회피)을 추가하여 더욱 생동감있는 전투 시스템을 개발했다.

 

 

 

마무리말


다음 편에서는 보완된 기능에 대한 설계의 고민들을 정리해볼 예정이다.

 

[ UE ] BehaviorTree Task 관리

닿메_dahme
|2025. 4. 28. 21:07

저번 프로젝트에서 적 AI 구현을 담당했었다.

그때는 AI를 처음 접하기도 하고, 어려웠어서 Task를 블루프린트로만 구현해서 관리했었다.

이번에 C++로도 Task 관리를 할 수 있는 것을 배우게 되어 두 구현 기법의 장단점을 정리해보려고 한다.

 

 

 

블루프린트로 구현했을 때

- 노드로 구성되어있어 실행 비용이 큼

- Task 호출이 빈번하거나 반복 호출이 많으면 프레임 드랍 가능성 많음

- 시각적이라 트리 흐름을 빠르게 파악 가능

- Task 로직이 복잡하고 입력인자가 많으면 노드 내부 파악하기 쉽지않음

- 디버깅하기 힘들고, 대형 프로젝트가 되면 담당자를 찾기 힘들고, 수정에 버거움

- Git 머지 충돌 위험이 높음(BP 파일 바이너리 저장 때문)

- 프로토타입으로 설계하기 매우 빠름

 

 

 

 

 

 

 

C++로 구현했을 때

- 블루프린트 대비 호출 오버헤드가 적음

- 대규모 프로젝트에서 관리하기 비교적 쉬움

- 로직이 명시적으로 관리되므로 디버깅이나 추적에 용이

- 코드를 분할하면 기능별로 Task를 관리할 수 있어 확장, 수정에 용이

- 초기 설계가 어렵고 복잡하지만 프로젝트 규모가 커질 수록 편하게 관리가능

- Task 실패 시 블랙보드 상태까지 추척 가능

 

 

 

 

 

 

주의사항

- Task는 비동기 작업하면 안 된다.
(Ex. 인터넷 호출, 긴 연산) → Task는 반드시 "짧고 확정적"으로 끝내야 한다.

- 성공/실패 조건을 명확히 분기하라.
ambiguous(모호)한 성공/실패 처리하면 BT 플로우가 꼬여서 AI가 멍때린다.

- Performance Critical한 부분만 먼저 C++화한다.
거리 계산, 콜리전 체크, Line Trace 같은 것부터 최적화 대상.

 

 

 

 

 

 

 

마무리 말

Task는 C++로 구현하는게 좋을 것 같다. 일단 확장성과 디버깅하기 너무 편리하다.

그리고 Decorator나 Service같은 부분은 블루프린트로 작성하면 빠르게 수정하는데 장점을 가져갈 수 있을 것 같다.

[ C++ ] 프로그래머스 비밀지도

닿메_dahme
|2025. 4. 25. 16:20

 

문제 링크

https://school.programmers.co.kr/learn/courses/30/lessons/17681

 

프로그래머스

SW개발자를 위한 평가, 교육, 채용까지 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프

programmers.co.kr

 

문제

 

나의 풀이

1. 각 배열이 들어가있는 10진수를 2진수로 변환

2 . 2진수로 변환된 배열의 값들을 순회하면서 비교, 두 배열 중 하나라도 1이 있다면 temp에 1로 저장

3. temp 순회하면서 1이면 #, 0이면 공백을 answer에 추가

+ 처음엔 배열에 들어가있는 수를 하나하나 이진수로 바꾸는 작업을 했었는데 하다보니 코드가 길어지기도 하고, 가독성이 너무 떨어졌다. 바꾸는 과정에서 함수를 만들고 이 함수는 vector<int>를 리턴하는데 이러면 2차원 벡터에 이진수들이 하나하나 들어가있어서 arr1과 arr2를 비교하기에 굉장히 비용이 많이들고, 뎁스도 깊어지는 설계를 하고있었다.

그러던 와중에 까맣게 잊고 있었던 비트 연산을 보게되었고, 기존 설계보다 훨씬 간결하고 가독성 좋게 문제를 풀 수 있었다.

 

최종 코드

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

using namespace std;

vector<string> solution(int n, vector<int> arr1, vector<int> arr2) {
    vector<string> answer;
    
    for (int i = 0; i < n; i++)
    {
        string temp = "";
        arr1[i] = arr1[i] | arr2[i];
        
        while (temp.size() < n)
        {
            if (arr1[i] % 2 == 1)
                temp.push_back('#');
            else if (arr1[i] % 2 == 0)
                temp.push_back(' ');
            
            arr1[i] /= 2;
        }
        
        reverse(temp.begin(), temp.end());
        answer.push_back(temp);
    }
    return answer;
}

 

 

다른 코드

#include <string>
#include <vector>
#include <iostream>
#include <bitset>

using namespace std;

vector<string> solution(int n, vector<int> arr1, vector<int> arr2) {
    vector<string> answer;

    for (int i = 0; i < n; i++) {
        bitset<16> bits(arr1[i] | arr2[i]);

        string row = bits.to_string().substr(16 - n);
        for (char& c : row)
            c = (c == '1') ? '#' : ' ';

        answer.push_back(row);
    }

    return answer;
}

여기서는 따로 이진수 변환작업을 해주지않고 바로 bitset을 활용하여 넣어준 예시이다.

substr은 16개의 비트 00000000011111 이런식으로 저장되는데 결국은 마지막 n 번째의 비트만 필요하기 때문에 잘라주었다.

 

 

마지막 말

처음엔 10진수에서 2진수로 변환해야한다는 생각과 OR을 써야겠다는 생각까지는 접근했는데 비트연산을 생각못하고 2진수로 변환하는데 하나하나 하느라 이 과정에서 막혀버린 것 같다.

비트 연산을 공부했지만 막상 코드를 짤 때는 사용해봤던 적이 적은데 이렇게 진법 계산할 때나, 부분집합 구할 때 많이 사용한다고 한다.

검색하다가 비트 마스트라는 용어도 보았는데 어떻게 구현하는지 공부해봐야겠다.

🔼 최종 결과물

최근 멀티플레이어 격투 게임 프로젝트에서 인게임 시스템을 담당하며, 플레이어 사망과 리스폰 처리에 대한 고민을 했다.

이 글에서는 내가 선택한 두 가지 방법과 그 근거, 트러블 슈팅까지 공유하려고 한다.

 

1. 리스폰 시스템 설계 방향 고민

고민 내용 : 즉시 리스폰 vs 대기 후 리스폰

최종 결정: 대기 후 리스폰

 

여러 옵션을 검토한 결과, 라운드 종료 후 일괄 부활 방식을 선택했다.

선택 이유

게임 밸런스 관점

  • 의미 있는 사망 페널티 : 즉시 부활 시 사망의 중요도가 떨어져 무모한 플레이를 유도할 가능성
  • 전략적 깊이 향상 : 생존의 가치가 높아지면서 플레이어들이 더 신중한 판단을 내리도록 유도

 

게임 플로우 관점

  • 명확한 라운드 구조 : 각 라운드가 독립적인 완결성을 가지며 게임 리듬감이 향상
  • 관전 몰입도 : 사망 후에도 남은 팀원의 활약에 집중할 수 있어 지속적인 몰입이 가능
  • 드라마틱한 연출 : 마지막 생존자의 플레이가 더욱 긴장감 넘치는 순간을 연출

 

2. 대기 중 관전 시스템 설계 방향 고민

고민 내용 : 전체 상황 관찰 카메라 vs 살아있는 플레이어 카메라 

최종 결정: 전체 상황 관찰 카메라

사망한 플레이어의 대기 시간 동안 맵 전체를 조망할 수 있는 관찰자 카메라로 선택해서 설계했다.
특히 이 두 방법 모두 흥미롭고, 유저들에게 흥미를 줄 수 있는 요소라 생각해서 고민하는데 어려웠다.. (나한테 있어선 취향차이?로 느껴졌기 때문이다..)
그래서 각 방법의 장단점을 분석하고 최대한 내 프로젝트에 적합하고 잘 맞는 요소가 많은 방법을 선택했다. 

 

각 방식의 장단점 분석

전체 상황 관찰 카메라 (선택한 방식)

장점

  • 전술적 학습: 맵 전체의 플레이 패턴과 전략을 관찰하여 다음 라운드에 활용 가능
  • 공정성 보장: 특정 플레이어에게만 유리한 정보를 제공하지 않음
  • 관전 재미: 영화적 시점으로 경기 전체를 감상할 수 있어 엔터테인먼트 요소 증가

단점

  • 구현 복잡도: 적절한 카메라 추적 로직과 자동 시점 전환 시스템 필요
  • 몰입감 저하: 1인칭 관점 대비 개인적 연결감이 상대적으로 낮을 수 있음

 

살아있는 플레이어 빙의 (대안)

장점

  • 높은 몰입도: 1인칭 시점으로 더욱 생생한 관전 경험 제공
  • 스킬 학습: 숙련된 플레이어의 세밀한 조작과 판단을 직접 관찰 가능
  • 구현 용이성: 기존 플레이어 카메라 시스템을 그대로 활용

단점

  • 정보 누설 위험: 사망한 플레이어가 빙의를 통해 얻은 정보를 전달할 수 있어 게임 무결성 손상
  • 선택 로직 복잡성: 빙의 대상 선정 알고리즘과 전환 타이밍 결정이 어려움

 

캐릭터가 사망하고 리스폰되었을 때를 처리하는 부분을 설계하다가 단순히 다른 컨트롤러에 빙의되는 것이 아니라 격투게임이니만큼 격투를 관전할 수 있는 관전뷰를 만들어 거기에 플레이어들을 빙의시켰다.


트러블슈팅

하다가 서버에서 클라이언트의 플레이어컨트롤러를 관전 카메라에 배치하는건 잘 작동되었다.

void ACharacterController::ClientSpectateCamera_Implementation(ACameraActor* SpectatorCam)
{
	if (!SpectatorCam)
	{
		UE_LOG(LogTemp, Warning, TEXT("ClientSpectateCamera: SpectatorCam is null"));
		return;
	}

	UnPossess();
	ChangeState(NAME_Spectating);
	SetViewTargetWithBlend(SpectatorCam, 0.f);

	UE_LOG(LogTemp, Log, TEXT("ClientSpectateCamera: switched to SpectatorCamera"));
}
void ACharacterController::NotifyPawnDeath()
{
	AArenaPlayerState* ArenaPlayerState = GetPlayerState<AArenaPlayerState>();
	if (!ArenaPlayerState)
	{
		UE_LOG(LogTemp, Warning, TEXT("NotifyPawnDeath: PlayerState not invaild"));
		return;
	}

	if (ArenaPlayerState->MaxLives > 0)
	{
		ArenaPlayerState->MaxLives--;

		FTimerHandle RespawnTimerHandle;
		GetWorld()->GetTimerManager().SetTimer(RespawnTimerHandle, [this]()
			{
				if (AArenaGameMode* ArenaGameMode = Cast<AArenaGameMode>(GetWorld()->GetAuthGameMode()))
				{
					ArenaGameMode->RespawnPlayer(this);
				}
			}, 3.0f, false);
	}
	else
	{
		if (AArenaGameState* ArenaGameState = Cast<AArenaGameState>(GetWorld()->GetGameState()))
		{
			float CurrentRoundTime = ArenaGameState->GetRemainingTime();
			float TotalRountTime = ArenaGameState->GetRoundStartTime();
			ArenaPlayerState->SetSurvivalTime(TotalRountTime - CurrentRoundTime);
			UE_LOG(LogTemp, Log, TEXT("NotifyPawnDeath: Survivla time is %.2f"), CurrentRoundTime);
		}

		if (AArenaGameMode* ArenaGameMode = Cast<AArenaGameMode>(GetWorld()->GetAuthGameMode()))
		{
			if (ACameraActor* SpectatorCam = ArenaGameMode->GetSpectatorCamera())
			{
				ClientSpectateCamera(SpectatorCam);
			}
			else
			{
				UE_LOG(LogTemp, Warning, TEXT("NotifyPawnDeath: SpectatorCamera not invaild"));
			}
		}
	}
}

이건 처음에 작성했던 코드인데, 클라이언트 RPC로 구현했다.

그런데 저 카메라 전환에 대해 0.2초정도는 원하는 카메라로 이동되었다가 갑자기 이상한 뷰로 전환되는 것이 아닌가!

솔직히 로직상으로는 뭐가 문제인지 잘 찾을 수 없었던 것 같다.

 

하지만 찾아내야했다..

 

카메라는 블루프린트로 만들어진 카메라 액터였고, 컨트롤러에 Tick 함수에 어떤 카메라가 빙의되는지 찾아봤다.

근데 내 관전카메라로 떴다가 컨트롤러로 뜨는 것이 아닌가..

아마 캐릭터쪽에서 Pawn Destroy()를 타이머로 관리하고, 바로 컨트롤러의 죽음 처리 함수를 호출한다.

컨트롤러 죽음 함수에서 카메라 전환을 위해 컨트롤러를 UnPossess하고, SetViewTarget을 해주게되는데 카메라 빙의 함수 호출 순서가 꼬여서 그런 것 같다.

(디버깅에만 4시간 쓴 것 같다...)

 

void ACharacterController::NotifyPawnDeath()
{
	if (!HasAuthority()) { return; }

	AArenaPlayerState* ArenaPlayerState = GetPlayerState<AArenaPlayerState>();
	if (!IsValid(ArenaPlayerState)) { return; }

	AArenaGameMode* ArenaGameMode = Cast<AArenaGameMode>(GetWorld()->GetAuthGameMode());
	if (!IsValid(ArenaGameMode)) { return; }

	UnPossess();
	DisableInput(this);
	FlushPressedKeys();
	SetIgnoreMoveInput(true);
	SetIgnoreLookInput(true);

	ArenaPlayerState->MaxLives--;

	// Posses Spectator Camera
	if (AArenaGameState* ArenaGameState = Cast<AArenaGameState>(GetWorld()->GetGameState()))
	{
		FTimerHandle SpectateHandle;
		ACameraActor* SpectatorCam = ArenaGameState->GetSpectatorCamera();
		if (IsValid(SpectatorCam))
		{
			GetWorld()->GetTimerManager().SetTimer(SpectateHandle, [this, SpectatorCam]()
				{
					FViewTargetTransitionParams Params;
					Params.BlendTime = 0.f;
					Cast<APlayerController>(this)
						->ClientSetViewTarget(SpectatorCam, Params);
				}, 0.2f, false);
		}

		if (IsLocalController())
		{
			GetWorld()->GetTimerManager().SetTimer(SpectateHandle, [this, SpectatorCam]()
				{
					FViewTargetTransitionParams Params;
					Params.BlendTime = 0.f;
					Cast<APlayerController>(this)
						->ClientSetViewTarget(SpectatorCam, Params);
				}, 0.2f, false);
		}
	}

	// Respawn
	AArenaPlayerState* PS = GetPlayerState<AArenaPlayerState>();
	const bool bHasLives = PS && PS->MaxLives > 0;

	if (ArenaGameMode->SelectedArenaSubMode == EArenaSubMode::DeathMatch)
	{
		ClientStartRespawnCountdown();
		FTimerHandle RespawnTimerHandle;
		GetWorld()->GetTimerManager().SetTimer(
			RespawnTimerHandle,
			[this, ArenaGameMode]()
			{
				ArenaGameMode->SpawnPlayer(this);
			},
			5.0f,
			false
		);
	}
	else if (ArenaGameMode->SelectedArenaSubMode == EArenaSubMode::Elimination)
	{
		if (bHasLives)
		{
			ClientStartRespawnCountdown();
			FTimerHandle RespawnTimerHandle;
			GetWorld()->GetTimerManager().SetTimer(
				RespawnTimerHandle,
				[this, ArenaGameMode]()
				{
					ArenaGameMode->SpawnPlayer(this);
				},
				5.0f,
				false
			);
		}
		else
		{
			if (AArenaGameState* ArenaGameState = Cast<AArenaGameState>(GetWorld()->GetGameState()))
			{
				float SurvivalTime = ArenaGameState->GetRoundStartTime() - ArenaGameState->GetRemainingTime();
				ArenaPlayerState->SetSurvivalTime(SurvivalTime);
				ClientShowDieMessage();
			}
		}
	}
}

여기 함수를 보면 ClientSetViewTarget을 호출하는데 이건 언리얼 내장 RPC 함수이고 호출될 때 지연시간이 있다고 한다.

그래서 충분히 UnPossess되고, 빙의되고 이런 과정을들 기다렸다가 호출하기 때문에 순서가 꼬이지 않아 정상적으로 카메라 전환이 되었던 것 같다.

또는 컨트롤러에서 빙의해제가 되기 전에  RPC 함수가 호출되면서 컨트롤러의 빙의 처리와 SetViewTarget의 호출시점이 꼬여서 발생했을 수도 있을 것 같다,

 

 


마무리말

사망과 리스폰 기능을 구현하면서 어떻게 설계하면 좋을지에 대한 많은 고민을 했던 값진 경험이 되었다.

더욱 다양한 게임을 접해서 다른 방식들도 구현해보고 싶다.

그리고 함수 호출 시점에 대헤 아직 언리얼 내장 함수들이 어떻게 동작하는지 명확하게 이해하지 못한 것 같다.

문제 해결은 했지만 혼자서 함수와 코드를 따라가보면서 유추한것뿐이라, 나중에 시간을 따로 내어서 내장 함수를 깊에 들여다보는 시간을 가져야겠다.

 

훈련장 모드에서 타이머를 구현하다가 발생한 트러블상황과 구조 대공사를 통해 알게된 내용을 정리해보려고 한다.

 

 

 

트러블 슈팅

 

트러블 상황

RoundTime을 0으로 초기화해줬는데 갑자기 이상한 값이 뜸..

 

해결방법

기존 코드에서 Time이 0일때도 1초씩 감소하는 로직을 타서 발생했던 현상.
Time이 아직 초기화되지않았을 때에는 1초씩 감소하지 않도록 방어 코드를 짜주어서 해결.

 

 

 

 

 

프로젝트 진행 중 다시 알게된 내용

GameMode, GameState, Widget 간의 데이터 처리

게임 프레임워크를 설계하고 구현하다보니 구조적인 설계에 대해 많이 고민하고 성장하게 되었다.

GameMode, GameState, Widget 간의 데이터 처리에 부분에서 흐름이 어떻게 흘러가는지 이해하게되었다.

나는 GameState가 실시간으로 데이터를 저장하는 구조라서 데이터 업데이트하는 로직을 여기에서 처리하고 있었다.

근데 다시 공부해보니 로직이나 계산 같은 부분은 GameMode에서 처리하고 GameState는 데이터를 저장하는 역할하고 GameMode에서 State의 데이터를 업데이트해주도록 가져가는게 좋을 것 같았다.

Widget은 GameMode에서 State의 값을 가져오거나 바로 State에서 가져가는 구조로 설계했는데 지금 프로젝트 규모는 크지 않기때문에 바로 State에서 값을 가져가도록 설계했다.

 

 

 

 

 

결과

 

타이머 연동 ? OK !

Start, Reset 버튼 온클릭 함수 ? OK !

TotalDamage, DPS 연동 ? OK !

 

잘 작동한다. 구조도 싸그리 대공사를 했기때문에 나중에 유지보수하기 더 쉬울 것 같다.

 

 

 

 

 

 

첫번째 트러블 상황

BaseInGameHUD에서 정의한 GetTogglePauseWidget을 PlayerController에서 가져오기 위해 IsValid로 값체크를 하는 과정에서 발생.

 

 

 

이유

IsValid()는 UObject* 타입을 요구하는데 GetTogglePauseWidget()은 const 포인터를 반환하고 있음. IsValid()는 주로 non-const 버전을 검사하게 구현되어있어서 생기는 문제.

 

 

 

해결방법
	UFUNCTION(BlueprintCallable, Category = "CCFF|UI")
	FORCEINLINE UTogglePauseWidget* GetTogglePauseWidget() const { return TogglePauseWidget; }


IsValid()를 쓰지않고 ≠nullptr로 바꿔줘서 해결

 

 

 

두번째 트러블 상황

void UTrainingWidget::UpdateTimer()
{

	CurrentTime -= 1.0f;

	if (CurrentTime <= 0.0f)
	{
		CurrentTime = 0.0f;
		GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
	}

	int32 Minutes = FMath::FloorToInt(CurrentTime / 60.0f);
	int32 Seconds = FMath::FloorToInt(CurrentTime) % 60;
	FString FormattedTime = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);

	UpdateTimerText(FormattedTime);
}

이런 구조로 가는데 Base의 TimerText가 업데이트되지 않았다.

 

 

 

이유 및 해결 방법
void UTrainingWidget::UpdateTimer()
{

	CurrentTime -= 1.0f;

	if (CurrentTime <= 0.0f)
	{
		CurrentTime = 0.0f;
		GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
	}

	int32 Minutes = FMath::FloorToInt(CurrentTime / 60.0f);
	int32 Seconds = FMath::FloorToInt(CurrentTime) % 60;
	FString FormattedTime = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);

	if (APlayerController* PC = GetOwningPlayer())
	{
		if (ABaseInGameHUD* HUD = Cast<ABaseInGameHUD>(PC->GetHUD()))
		{
			if (UBaseInGameWidget* BaseWidget = HUD->GetBaseInGameWidget())
			{
				BaseWidget->UpdateTimerText(FormattedTime);
			}
		}
	}
}


TrainingWidget에서는 직접 TimerText를 갖지 않고, 대신 현재 HUD에 있는 BaseInGameWidget의 UpdateTimerText() 함수를 호출해서 타이머 텍스트를 갱신하는 게 내 예상 결과값이었다.

즉, TrainingWidget 내부에서 "this->UpdateTimerText(...)"를 호출하면 TrainingWidget 자체의 (없거나 null인) TimerText에 업데이트가 적용된다고 한다. 그래서 대신, HUD에 있는 BaseInGameWidget 인스턴스를 찾아서 그 인스턴스의 UpdateTimerText()를 호출해주는 방식으로 수정해주었다.

 

 

 

마지막 말

오늘 타이머 작업을 하다가 아무래도 위젯클래스에서 로직을 타는게 좀 이상하다고 생각이 들었다..

그리고 GameMode와 GameState의 역할? 분리를 어떻게 해야할지 감을 못잡고 작업하는 상태여서 계속 하면서 이상하다는 생각을 했다..

아니나다를까.. 이상한 구조로 작업하고 있어서 아예 구조를 전면변경하려고 한다..

눈에서 무슨 액체가 흐른다..

위젯에 텍스트로 타이머 기능을 넣는 부분을 진행했다.

 

BaseHUD, BaseWidget 설계

위젯을 쓰려고하니 모드마다 공통으로 들어가는 위젯들이 있어서 Base 위젯을 만들면 좋겠다고 생각했다.

베이스 HUD와 Widget의 구조를 잡는데 오늘 대부분의 시간을 썼었던 것 같다.

HUD와 Widget의 역할을 잘 몰랐었는데 HUD는 UI를 담는 컨테이너 같은 역할이고 그 안에 실질적으로 유저가 보는 것들이 UI라고 이해했다.

 

그래서 일단 BaseHUD를 설계했고, 공통된 부분만 묶어서 정의해주었다.

새로운 HUD가 추가될 때마다 이 BaseHUD를 상속받아서 적용할 수 있게 설계했고, 위젯도 비슷한 방식을 적용했다.

근데 위젯은 어떤걸 묶을지 아직 감이 안잡히는 것 같다.. Base가 굳이 필요한가 싶기도하고.. 주말에 좀 더 정리를 해봐야겠다.

 

 

Text 위젯과 타이머 연동

타이머 연동은

00:00 이런 식으로 하려고 설계했고, 하나의 Text를 사용해서 만들어주었다.

 

이렇게 중간에 시간을 넣어주었고 위젯을 상속받은 C++을 기반으로 만들어진 위젯블루프린트이기때문에, 특정 위젯을 가져야만 오류가 나지 않도록 처리해주었다.

베이스 UI를 이렇게 만들어주었고, 이제 이 베이스 UI를 상속받은 훈련장 UI를 만들어주었다.

 

이런 느낌으로 초를 입력하면 화면 중앙 상단의 타이머까지 연동할 수 있게 하고 싶었다.

시작 버튼을 누르면 설정한 초만큼 시작이되고, 리셋을 누르면 타이머가 0초로 돌아가게 설정해주려고 한다.

 

#pragma once

#include "CoreMinimal.h"
#include "Framework/UI/BaseInGameWidget.h"
#include "TrainingWidget.generated.h"

UCLASS()
class CCFF_API UTrainingWidget : public UBaseInGameWidget
{
	GENERATED_BODY()
	
	UFUNCTION()
	void OnStartButtonClicked();

	UFUNCTION()
	void OnResetButtonClicked();

	UFUNCTION()
	void UpdateTimer();

protected:
	virtual void NativeConstruct() override;

	float CurrentTime = 0.0f;

	FTimerHandle TimerHandle;

	UPROPERTY(meta = (BindWidget))
	class UTextBlock* TimerText;
	
	UPROPERTY(meta = (BindWidget))
	class UEditableTextBox* TimeInputBox;

	UPROPERTY(meta = (BindWidget))
	class UButton* StartButton;

	UPROPERTY(meta = (BindWidget))
	class UButton* ResetButton;

};
#include "Framework/UI/TrainingWidget.h"
#include "Components/Button.h"
#include "Components/EditableTextBox.h"
#include "Components/TextBlock.h"
#include "TimeManagementClasses.h"


void UTrainingWidget::NativeConstruct()
{
	Super::NativeConstruct();

	if (IsValid(StartButton))
	{
		StartButton->OnClicked.AddDynamic(this, &UTrainingWidget::OnStartButtonClicked);
	}

	if (IsValid(ResetButton))
	{
		ResetButton->OnClicked.AddDynamic(this, &UTrainingWidget::OnResetButtonClicked);
	}

	if (IsValid(TimerText))
	{
		TimerText->SetText(FText::FromString(TEXT("00:00")));
	}
}


void UTrainingWidget::OnStartButtonClicked()
{
	if (IsValid(TimeInputBox))
	{
		const FString InputStr = TimeInputBox->GetText().ToString();
		float EnterTime = FCString::Atof(*InputStr);

		if (EnterTime < 0.f)
			EnterTime = 0.f;

		CurrentTime = EnterTime;

		int32 Minutes = FMath::FloorToInt(CurrentTime / 60.0f);
		int32 Seconds = FMath::FloorToInt(CurrentTime) % 60;

		FString FormattedTime = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);

		if (IsValid(TimerText))
		{
			TimerText->SetText(FText::FromString(FormattedTime));
		}

		// initialize timer
		GetWorld()->GetTimerManager().ClearTimer(TimerHandle);

		GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &UTrainingWidget::UpdateTimer, 1.0f, true);
	}
}

void UTrainingWidget::OnResetButtonClicked()
{
	if (IsValid(TimerText))
	{
		TimerText->SetText(FText::FromString(TEXT("00:00")));
	}

	GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
}

void UTrainingWidget::UpdateTimer()
{
	CurrentTime -= 1.0f;

	if (CurrentTime <= 0.0f)
	{
		CurrentTime = 0.0f;
		GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
	}

	int32 Minutes = FMath::FloorToInt(CurrentTime / 60.0f);
	int32 Seconds = FMath::FloorToInt(CurrentTime) % 60;
	FString FormattedTime = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);

	if (IsValid(TimerText))
	{
		TimerText->SetText(FText::FromString(FormattedTime));
	}
}

 

Editable Text Box에서 입력된 텍스트를 가져와서 형변환을 해주고, 분과 초를 나눠서 타이머에 적용시켜주었다.

 

블루프린트로도 버튼에 대한 온클릭 함수를 구현할 수 있었지만 이번에 진행하는 프로젝트는 UI가 많아지고 Base 위젯을 상속받아 사용하기 때문에 혼동될 수도 있어서 안전하게 코드로 온클릭 함수를 연결해주었다.

 

 

결과

이렇게 입력한 값은 항상 초로 인식하고 라운드 시계에서는 저렇게 분과 초로 나눠서 타이머를 구현해보았다.

 

 

마지막 말

초반에 역할 분리가 애매하게 된 상황에서 클래스를 구현하다보니 내 담당이 아닌 부분까지 함수를 만들어뒀었다.

다른 팀원의 혼동을 막기 위해 주말에 쓸데없는 함수는 다 과감하게 없애버리고, 중복되는 코드들을 줄여볼 생각이다.

훈련장 모드를 완성하고, 멀티 서버 공부를 더 해봐야겠다.

프로젝트에서 내가 담당한 인게임의 구조를 다시 한 번 틀을 잡아가는 단계를 거쳐야할 것 같다.

코드를 짜면서 구조 설계가 미흡해서 자꾸 딴 길로 새면서 기능 하나를 구현하는데 쓸데없이 많은 시간을 쓰고 있는 것 같다.