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을 해주게되는데 카메라 빙의 함수 호출 순서가 꼬여서 그런 것 같다.
TrainingWidget에서는 직접 TimerText를 갖지 않고, 대신 현재 HUD에 있는 BaseInGameWidget의 UpdateTimerText() 함수를 호출해서 타이머 텍스트를 갱신하는 게 내 예상 결과값이었다.
즉, TrainingWidget 내부에서 "this->UpdateTimerText(...)"를 호출하면 TrainingWidget 자체의 (없거나 null인) TimerText에 업데이트가 적용된다고 한다. 그래서 대신, HUD에 있는 BaseInGameWidget 인스턴스를 찾아서 그 인스턴스의 UpdateTimerText()를 호출해주는 방식으로 수정해주었다.
마지막 말
오늘 타이머 작업을 하다가 아무래도 위젯클래스에서 로직을 타는게 좀 이상하다고 생각이 들었다..
그리고 GameMode와 GameState의 역할? 분리를 어떻게 해야할지 감을 못잡고 작업하는 상태여서 계속 하면서 이상하다는 생각을 했다..