일정표
| 시간 | 할 일 | 비고 |
| 08:30~10:00 | 코드카타, 디자인 패턴 | |
| 10:00~13:00 | 개인 프로젝트 | |
| 14:00~18:00 | 2시 스크럼, 팀 프로젝트 | |
| 19:00~21:00 | 저녁 스크럼, 팀 프로젝트 | |
| 21:00~22:00 | 운동 | |
| 22:00~23:30 | 개인 프로젝트 |
*오늘의 코드카타*
문제. 과일 장수
과일 장수가 사과 상자를 포장하고 있습니다. 사과는 상태에 따라 1점부터 k점까지의 점수로 분류하며, k점이 최상품의 사과이고 1점이 최하품의 사과입니다. 사과 한 상자의 가격은 다음과 같이 결정됩니다
한 상자에 사과를 m개씩 담아 포장합니다.
상자에 담긴 사과 중 가장 낮은 점수가 p (1 ≤ p ≤ k)점인 경우, 사과 한 상자의 가격은 p * m 입니다
과일 장수가 가능한 많은 사과를 팔았을 때, 얻을 수 있는 최대 이익을 계산하고자 합니다.(사과는 상자 단위로만 판매하며, 남는 사과는 버립니다)
예를 들어, k = 3, m = 4, 사과 7개의 점수가 [1, 2, 3, 1, 2, 3, 1]이라면, 다음과 같이 [2, 3, 2, 3]으로 구성된 사과 상자 1개를 만들어 판매하여 최대 이익을 얻을 수 있습니다
(최저 사과 점수) x (한 상자에 담긴 사과 개수) x (상자의 개수) = 2 x 4 x 1 = 8
사과의 최대 점수 k, 한 상자에 들어가는 사과의 수 m, 사과들의 점수 score가 주어졌을 때, 과일 장수가 얻을 수 있는 최대 이익을 return하는 solution 함수를 완성해주세요
#include <vector>
#include <algorithm>
using namespace std;
int solution(int k, int m, vector<int> score) {
int answer = 0;
sort(score.begin(), score.end(), greater<int>());
for (int i = 0; i + m <= score.size(); i += m)
{
int min_score = score[i + m - 1];
answer += min_score * m;
}
return answer;
}
sort(..., greater<int>()): 점수를 큰 순서대로 정렬하여, 매 상자마다 가능한 한 높은 점수의 사과가 최저 점수가 되도록 보장한다.
반복문 조건 i + m <= score.size(): 남은 사과가 m개 미만이면 상자를 만들 수 없으므로, 딱 m개씩 채울 수 있는 경우에만 반복을 수행하여 남는 사과를 자동으로 제외한다.
시간 복잡도: 정렬에 $O(N \log N)$이 소요된다.
*디자인 패턴*
Case 2 : 고성능 액션 게임을 위한 프레임 단위 액션 버퍼링 & 콤보 시스템
명작 액션 게임들이 보여주는 착 감기는 손맛, 즉 입력 반응성의 원리를 알아보자.
1. C++ 개념 및 코드
전통적인 소프트웨어 디자인에서 입력 버퍼링과 콤보 시스템은 커맨드(Command) 패턴과 유한 상태 기계(FSM)의 결합으로 구현된다.
입력 이벤트가 발생했을 때 즉시 플레이어의 행동 함수를 실행하는 대신, 입력 자체를 '커맨드 객체(명령어)'로 캡슐화하여 큐(Queue)에 적재한다. 그리고 캐릭터의 현재 상태를 관리하는 FSM이 매 프레임 이 큐를 확인하여, 현재 행동을 취할 수 있는 상태(예: Idle)일 때 최전방의 명령을 꺼내어 실행하는 구조이다.
이때 큐에 담긴 명령은 영원히 유지되는 것이 아니라, 격투/액션 게임의 특성에 맞게 '밀리초(ms) 단위의 유효 시간(TTL: Time To Live)'이 지나면 자동으로 소멸하는 버퍼링 메커니즘을 가지게 된다.
#include <iostream>
#include <queue>
#include <memory>
// 1. 입력 명령의 종류 정의
enum class EInputOrder { LightAttack, HeavyAttack, Dodge };
// 2. 명령어(Command) 구조체: 유효 시간(TTL) 개념 포함
struct ActionCommand {
EInputOrder Order;
float TimeToLive; // 이 명령이 버퍼 안에서 살아있을 시간 (초 단위)
};
// 3. 캐릭터의 가상 FSM 상태
enum class ECharacterState { Idle, Attacking, Dodging };
// 4. 순수 C++ 입력 버퍼링 시스템
class InputBufferQueue {
private:
std::queue<ActionCommand> Buffer;
const float MAX_BUFFER_TIME = 0.3f; // 300ms 선입력 유효 시간
public:
// 사용자가 버튼을 누르면 버퍼 큐에 적재
void PushCommand(EInputOrder NewOrder) {
Buffer.push({ NewOrder, MAX_BUFFER_TIME });
std::cout << "[입력 수집] 새로운 명령 버퍼 진입 (유효시간 300ms)\n";
}
// 매 프레임 유효시간을 차감하고, 만료된 명령은 버림
void UpdateBuffer(float DeltaTime, ECharacterState CurrentState) {
// 1. 오래된 선입력 만료 처리
if (!Buffer.empty()) {
Buffer.front().TimeToLive -= DeltaTime;
if (Buffer.front().TimeToLive <= 0.0f) {
Buffer.pop();
std::cout << "[버퍼 만료] 너무 오래된 선입력이 소멸했습니다.\n";
}
}
// 2. 캐릭터가 행동 가능한 상태(Idle)일 때 버퍼의 명령을 실행
if (CurrentState == ECharacterState::Idle && !Buffer.empty()) {
ActionCommand ExecutingCmd = Buffer.front();
Buffer.pop(); // 큐에서 해제
std::cout << "[명령 실행] 버퍼링된 명령을 꺼내어 실행합니다: ";
switch (ExecutingCmd.Order) {
case EInputOrder::LightAttack: std::cout << "약공격 발동!\n"; break;
case EInputOrder::HeavyAttack: std::cout << "강공격 발동!\n"; break;
case EInputOrder::Dodge: std::cout << "회피 발동!\n"; break;
}
}
}
};
2. 언리얼 C++ 적용
위의 C++ 개념을 모던 언리얼 엔진 5(UE5) 환경에서 상업용 수준의 액션 게임으로 끌어올리려면 다음과 같은 엔진 고유 시스템과의 융합 및 최적화가 필수적이다.
가비지 컬렉션(GC) 및 구조체 최적화: 초당 수십 번 쌓이고 사라지는 입력 명령들을 매번 new나 UObject 포인터로 동적 할당하면 언리얼 GC에 엄청난 과부하(GC Spike)가 발생한다. 실무에서는 무조건 값 타입(Value Type)인 USTRUCT 기반 고정형 배열(TArray)을 커맨드 큐로 사용하여 가상 메모리 단에서 렉을 원천 차단하게 된다.
향상된 입력(Enhanced Input) 연계: UE5의 향상된 입력 시스템은 자체적으로 입력의 지속성이나 압력을 측정하므로, C++ 버퍼 컴포넌트는 오직 "어떤 UInputAction이 언제 들어왔는가"의 메타데이터만 래핑하여 큐에 쌓는 역할을 전담해야 한다.
애니메이션 프레임과의 동기화 (AnimNotifyState): 액션 게임에서 콤보 시스템의 성패는 애니메이션과의 완벽한 결합에 있다. 1타 공격 모션이 실행되는 도중, 아무 때나 다음 공격이 나간다면 모션이 괴상하게 찢어지게 된다. 따라서 프로그래머는 UAnimNotifyState를 커스텀 구현하여 애니메이션 타임라인 상에 "선입력을 허용하는 구간(Buffer Window)"과 "실제 연계 콤보가 발동되는 구간(Combo Execution Window)"을 프레임 단위로 기획자가 제어할 수 있도록 구현해 놓아야 한다.
3. 언리얼 C++ 실무 코드
기획자가 에디터에서 애니메이션 에셋을 보면서 마우스 드래그로 콤보/선입력 타이밍을 밀리초 단위로 제어할 수 있는 디커플링된 액션 버퍼 시스템이다.
(사례 1 : ActionBufferComponent.h, 커맨드 데이터 구조체 및 버퍼 컴포넌트 정의)
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "GameplayTagContainer.h"
#include "ActionBufferComponent.generated.h"
// 1. 가볍고 명확한 명령 구분을 위해 언리얼 고유의 GameplayTag 시스템 활용
// 예: Action.Command.LightAttack, Action.Command.Dodge 등
USTRUCT(BlueprintType)
struct FBufferedCommand
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "ActionBuffer")
FGameplayTag CommandTag;
// 명령이 적재된 런타임 게임 시간 (GetWorld()->GetTimeSeconds())
UPROPERTY(BlueprintReadOnly, Category = "ActionBuffer")
float Timestamp = 0.0f;
FBufferedCommand() {}
FBufferedCommand(FGameplayTag InTag, float InTime) : CommandTag(InTag), Timestamp(InTime) {}
};
// 2. 캐릭터의 대기, 공격, 회피 등을 엄격하게 관리할 FSM 구조체
UENUM(BlueprintType)
enum class ECharacterActionState : uint8
{
Idle,
Attacking,
Dodging,
Stunned
};
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class UNREALDESIGNPATTERN_API UActionBufferComponent : public UActorComponent
{
GENERATED_BODY()
public:
UActionBufferComponent();
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
// 사용자가 버튼을 누르면 캐릭터 본체(Enhanced Input)에서 호출할 창구
void BufferInputOrder(FGameplayTag InputTag);
// 애니메이션 노티파이가 열어주고 닫아줄 상태 제어 함수들
void SetBufferWindowOpen(bool bIsOpen) { bIsBufferWindowOpen = bIsOpen; }
void SetComboExecutionWindowOpen(bool bIsOpen);
// 현재 FSM 상태 제어
void SetActionState(ECharacterActionState NewState) { CurrentActionState = NewState; }
ECharacterActionState GetActionState() const { return CurrentActionState; }
// 현재 콤보 진행도를 추적할 변수 (C++는 콤보가 몇 타째인지만 기억하고 모션 분기는 컴포넌트가 제어)
UPROPERTY(BlueprintReadWrite, Category = "ActionBuffer|Combo")
int32 CurrentComboCount = 0;
private:
// 선입력 유효 기간 제한 (300ms = 0.3초)
const float COMMAND_TTL = 0.3f;
// GC 부하가 전혀 없는 순수 값 타입 고정배열 커맨드 큐 [최적화 핵심]
TArray<FBufferedCommand> CommandQueue;
ECharacterActionState CurrentActionState = ECharacterActionState::Idle;
bool bIsBufferWindowOpen = false;
bool bIsComboExecutionWindowOpen = false;
// 만료된 선입력들을 청소하는 내부 함수
void CleanExpiredCommands();
// 버퍼링된 명령 중 조건에 맞는 액션이 있다면 즉시 꺼내어 캐릭터에게 실행 명령을 내림
void TryConsumeBufferedAction();
};
(사례 2 : ActionBufferComponent.cpp, 버퍼링 매니저 로직 및 소비 구현부)
void UActionBufferComponent::TryConsumeBufferedAction()
{
if (CommandQueue.Num() == 0) return;
// 가장 먼저 들어온 명령(최전방) 확인
FBufferedCommand NextCmd = CommandQueue[0];
ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
if (!OwnerCharacter) return;
// [커맨드 + FSM의 융합]
// 캐릭터 상태와 명령 종류에 따른 최종 분기 처리 규칙 적용
if (NextCmd.CommandTag.MatchesTagExact(FGameplayTag::RequestGameplayTag(TEXT("Action.Command.Dodge"))))
{
// 회피는 공격 콤보 창과 상관없이 무조건 최우선으로 버퍼에서 꺼내어 즉시 실행 (캔슬 구르기)
CommandQueue.RemoveAt(0);
CurrentActionState = ECharacterActionState::Dodging;
bIsComboExecutionWindowOpen = false; // 콤보 창 강제 폐쇄
// 캐릭터 본체의 블루프린트 이벤트나 C++ 회피 함수 구동 위임
// OwnerCharacter->PlayDodgeMontage();
return;
}
if (bIsComboExecutionWindowOpen && NextCmd.CommandTag.MatchesTagExact(FGameplayTag::RequestGameplayTag(TEXT("Action.Command.LightAttack"))))
{
// 콤보 타이밍이 열려있고 다음 명령이 약공격이라면 콤보 카운트를 올리고 큐에서 소비
CommandQueue.RemoveAt(0);
CurrentComboCount++; // 1타 -> 2타 -> 3타 빌드업
bIsComboExecutionWindowOpen = false; // 연계가 시작되었으므로 중복 발동 차단하기 위해 닫음
CurrentActionState = ECharacterActionState::Attacking;
// 인터페이스나 전용 함수를 통해 캐릭터에게 알맞은 공격 애니메이션(몽타주) 재생 명령 하달
// OwnerCharacter->PlayAttackComboMontage(CurrentComboCount);
GE_ENGINE_LOG(LogTemp, Log, TEXT("[Combo Link] %d 타 연계 공격 몽타주가 부드럽게 발동"), CurrentComboCount);
}
}
(사례 3 : ANS_ComboWindow.h, 애니메이션 타임라인의 컨트롤러(커스텀 애님 노티파이 상태))
#pragma once
#include "CoreMinimal.h"
#include "Animation/AnimNotifies/AnimNotifyState.h"
#include "ActionBufferComponent.h"
#include "ANS_ComboWindow.generated.h"
/**
* 공격 애니메이션 몽타주 내부에 드래그로 배치할 콤보 타이밍 노티파이 상태 클래스
*/
UCLASS()
class UNREALDESIGNPATTERN_API UANS_ComboWindow : public UAnimNotifyState
{
GENERATED_BODY()
public:
// 노티파이 바(Bar)가 시작되는 시점에 C++ 컴포넌트의 창구를 열어줌
virtual void NotifyBegin(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, float TotalDuration, const FAnimNotifyEventReference& EventReference) override
{
if (MeshComp && MeshComp->GetOwner())
{
if (auto* BufferComp = MeshComp->GetOwner()->FindComponentByClass<UActionBufferComponent>())
{
BufferComp->SetComboExecutionWindowOpen(true);
BufferComp->SetBufferWindowOpen(true);
}
}
}
// 노티파이 바가 완전히 끝나는 시점까지 플레이어가 연계 버튼을 안 누르면 창구를 닫고 콤보를 끊음
virtual void NotifyEnd(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, const FAnimNotifyEventReference& EventReference) override
{
if (MeshComp && MeshComp->GetOwner())
{
if (auto* BufferComp = MeshComp->GetOwner()->FindComponentByClass<UActionBufferComponent>())
{
BufferComp->SetComboExecutionWindowOpen(false);
BufferComp->SetBufferWindowOpen(false);
// 이 노티파이 구간이 끝날 때까지 공격 연계가 안 일어났다면,
// 공격이 완전히 끝나 후딜레이(Recovery)로 돌아갔음을 의미하므로 콤보 카운트 리셋 및 Idle 복귀
if (BufferComp->GetActionState() == ECharacterActionState::Attacking)
{
BufferComp->CurrentComboCount = 0;
BufferComp->SetActionState(ECharacterActionState::Idle);
}
}
}
}
};
아키텍처 핵심 포인트 :
밀리초 단위 유효성 검증 (CleanExpiredCommands): 플레이어가 보스의 광역 공격을 보고 당황해서 약공격 버튼을 난타하다가 마지막에 회피(구르기)를 눌렀다고 가정해보자. 만약 선입력 큐의 유효 시간 제한이 없다면, 보스의 공격이 끝나고 경직이 풀리는 순간 옛날에 눌렀던 약공격들이 순서대로 다 발동되다가 맞아 죽는 조작감 대참사가 발생한다. 이 코드의 COMMAND_TTL 제어 덕분에 딱 최근 300ms 이내에 내린 명확한 의사결정만 인정된다.
RemoveAtSwap이 아닌 RemoveAt(0)의 이유: 일반적인 오브젝트 풀이나 정렬이 상관없는 격자 바구니에서는 성능을 위해 RemoveAtSwap을 사용하는 것이 좋지만, 입력 버퍼링은 무조건 '먼저 누른 버튼이 먼저 나가야 하는 선입선출(FIFO) 구조'를 지켜야 하므로 인덱스 순서 유지를 위해 RemoveAt(0)을 사용하는 것이 맞다. 대개 버퍼에 쌓이는 인풋은 많아야 2~3개 이므로 인덱스 밀림 오버헤드는 무시해도 될 만큼 미미하다.
4. 기획자가 할 일
프로그래머가 이렇게 데이터 주도형 시스템을 구현해 놓으면, 기획자는 에디터 작업만으로 타격감과 콤보 체인을 완성한다.
1단계: 커맨드 태그 등록 (프로젝트 세팅)
가장 먼저 어떤 버튼 조작(커맨드)이 존재할지 이름표를 붙여야 한다.
작업: 편집(Edit) -> 프로젝트 세팅(Project Settings) -> Gameplay Tags로 이동하고,
행동: 새 태그 추가 버튼을 눌러 C++ 시스템과 약속한 태그들을 등록하게 된다.
예: Action.Command.LightAttack (약공격)
예: Action.Command.HeavyAttack (강공격)
예: Action.Command.Dodge (회피)
***
2단계: 향상된 입력(Enhanced Input) 자산 바인딩
마우스 클릭이나 키보드 키를 방금 만든 태그와 매핑하는 과정이다.
작업: 콘텐츠 브라우저에서 우클릭하여 입력(Input) -> 입력 액션(Input Action)을 생성하고, (IA_LightAttack).
행동: 플레이어의 입력 매핑 컨텍스트(IMC_Default) 에셋을 열고, IA_LightAttack에 마우스 좌클릭(Left Mouse Button)을 매핑한다.
이후 캐릭터 블루프린트에서 이 입력이 들어올 때 C++ 컴포넌트의 BufferInputOrder(Action.Command.LightAttack) 함수로 태그만 토스해 주도록 노드 하나만 연결해 둔다.
***
3단계: 애니메이션 몽타주(Anim Montage) 섹션 분할
공격 애니메이션 에셋들을 연속해서 매끄럽게 재생할 수 있도록 편집하는 핵심 단계이다.
작업: 공격 애니메이션 시퀀스들을 모아 우클릭 후 애니메이션 몽타주 생성을 한다.
행동: 몽타주 에디터의 타임라인 영역에서 우클릭하여 섹션(Section)을 나눈다.
Combo1 섹션: 1타 휘두르기 모션 배치
Combo2 섹션: 2타 횡베기 모션 배치
Combo3 섹션: 3타 내려찍기 모션 배치
핵심 타임라인 설정: 각 콤보 섹션 뒤에는 버튼을 누르지 않았을 때 자연스럽게 기본 대기 상태로 돌아가는 후딜레이 모션(End1, End2) 섹션을 이어서 배치하고 섹션 링크를 끊어준다.
***
4단계: 마법의 타임라인 작업 - ANS_ComboWindow 배치 (가장 중요)
액션 게임의 '손맛'과 '반응성'을 밀리초 단위로 제어하는 콤보 시스템의 렌더링 심장부 작업이다.
작업: 몽타주 에디터의 Notifies 트랙을 확인한다.
행동: 트랙 빈 곳에서 우클릭 후 노티파이 상태 추가(Add Anim Notify State) -> ANS_ComboWindow를 선택한다.
Notify Begin (바의 시작점): 무기를 휘두르고 난 직후, "이 타이밍부터 다음 공격 버튼 선입력을 허용하겠다"는 시점에 놓는다.
Notify End (바의 끝점): 캐릭터의 공격 공격 모션이 완전히 끝나 손을 거두는 시점에 놓는다. 이때까지 사용자가 다음 버튼을 안 누르면 콤보 카운트가 초기화되며 부드럽게 대기(Idle) 모션으로 돌아간다.
마우스 드래그 튜닝: 타임라인에 생성된 바(Bar)의 양 끝을 마우스로 잡고 앞뒤로 늘리거나 줄이며 정확한 프레임 구간을 지정한다.
***
5단계: 캐릭터 블루프린트에서 컴포넌트 장착 및 스탯 조정
마지막으로 내 분신이 될 캐릭터에게 이 모든 버퍼 기능을 탑재해 준다.
작업: 캐릭터 블루프린트(BP_MyCharacter)를 열고,
행동: 좌상단 컴포넌트 창에서 컴포넌트 추가를 눌러 C++로 작성해 둔 ActionBufferComponent를 부착한다.
디테일 패널 튜닝: 컴포넌트를 클릭하면 우측 디테일 패널에 프로그래머 모드에서 설계해 둔 변수들이 노출된다.
"선입력 유효 시간(COMMAND_TTL)" 수치를 0.3(300ms) 혹은 0.2로 고쳐가며 손가락 감각에 완벽하게 들어맞는 최적의 반응 속도를 하드코딩 없이 실시간으로 찾아낸다.
*팀 프로젝트*
1. 시작하기에 앞서 개념 공부
게임에 사용할 박스 액터 담당이었으나 캐릭터에 부착할 컴포넌트, 캐릭터 상태, 캐릭터 조작 및 카메라 등의 코드 작업을 하고, 리슨 서버에서 잘 작동하는지 테스트를 하고, 디버깅까지 완료했다. 이제 캐릭터/박스는 다른 팀원에게 맡기고 UI를 담당하여 리슨 서버 환경에서도 각 클라이언트에서 원활하게 UI 및 위젯 바인딩이 되어 화면에 출력되도록 해보려 한다.
우선 UI 및 위젯 작업을 하기 전, 지금까지 팀프로젝트를 하면서 쌓인 코드들을 한번 더 살펴보고 누락되거나 필요한 부분이 있는지 점검하기로 했다.
우리 프로젝트(Parcel Knight)는 리슨 서버(Listen Server) 기반으로 확장할 계획을 가지고 있기 때문에, 싱글플레이 UI를 만들 때와는 아키텍처 접근 방식이 완전히 달라야 한다.
코드를 본격적으로 분석하고 작성하기 전에, 멀티플레이 환경에서 UI C++ 코드를 작성할 때 반드시 숙지해야 할 4가지 핵심 원칙이 있다.
<1. UI(Widget)는 오직 '로컬(Local)'에만 존재한다 (Replication 불가)>
언리얼 엔진에서 가장 많이 하는 실수 중 하나가 UI 위젯(UUserWidget) 헤더에 bReplicates = true;를 넣으려고 하거나, 위젯 내부에서 서버 RPC 함수를 만들려고 하는 것인데, UI는 네트워크 복제(Replication)가 절대 불가능하다. UI는 각 플레이어의 모니터 화면에만 그려지는 지극히 '로컬(Client-Side)' 에셋이다.
리슨 서버에서의 함정: 리슨 서버 환경에서는 방장(Host)도 플레이어이기 때문에 방장 화면에는 UI가 뜬다. 만약 서버(GameMode 등)에서 무심코 "UI 생성" 코드를 실행하면, 방장 화면에만 UI가 중복으로 생성되거나 클라이언트 화면에는 UI가 아예 안 뜨는 대참사가 발생한다. 따라서, UI 생성 및 초기화는 반드시 로컬 플레이어 컨트롤러(IsLocallyControlled())가 확보된 시점에 클라이언트 사이드에서만 실행되어야 한다.
<2. UI는 데이터를 가지지 않는다: '이벤트 기반(Event-Driven)' 갱신>
UI 위젯의 Tick 함수에서 매 프레임마다 캐릭터의 체력이나 팀 점수를 Cast해서 가져오는 방식(Polling)은 멀티플레이에서 성능 저하와 데이터 불일치의 주범이다. 데이터의 '진실(True Source)'은 서버가 통제하는 Actor나 Component에 있고, UI는 그 데이터가 복제되어 넘어오는 타이밍에 신호를 받아 '그리기'만 해야 한다.
팀 점수 / 남은 시간: GameState 산하의 TeamScoreComponent 내부 변수 TeamScore가 복제(OnRep_TeamScore)될 때, 클라이언트의 플레이어 컨트롤러를 거쳐 UI 위젯의 텍스트를 갱신해야 한다.
개인 점수 / 콤보: PlayerState 내부의 UPlayerStatComponent 데이터가 복제되는 타이밍에 내 화면의 HUD를 갱신한다.
<3. 오너십(Ownership)의 명확한 구분 (GetOwningPlayer)>
멀티플레이 게임에서는 하나의 월드에 여러 명의 플레이어와 플레이어 컨트롤러가 돌아다니게 된다. 따라서 UI 위젯이 "지금 내 화면을 보고 있는 주인이 누구인가?"를 정확히 알아야 한다.
무기/탄약 정보, 내 체력바 등을 표시할 때는 반드시 GetOwningPlayer() 또는 GetOwningPlayerPawn()을 사용해 이 위젯을 소유한 로컬 플레이어의 데이터를 파악해야 한다.
만약 그냥 UGameplayStatics::GetPlayerController(GetWorld(), 0)을 써버리면, 클라이언트 컴퓨터에서도 무조건 0번 컨트롤러(자기 자신)를 가리키긴 하지만, 리슨 서버(호스트)나 특수한 상황에서 엉뚱한 플레이어의 체력바가 내 화면에 표시되는 오작동이 발생할 수 있다.
<4. UI에서 서버로의 요청은 반드시 '통로'를 거쳐야 한다.>
유저가 UI 상에서 버튼을 누르거나(예: 로비에서 준비 완료 버튼, 인게임 배송 요청 등) 조준점(크로스헤어) 정보를 통해 히트마커를 띄울 때, UI 컴포넌트가 직접 서버에 "나 점수 올려줘!"라고 말할 수 없다. UI는 오너십을 가질 수 없어서 서버 RPC 호출 권한이 없기 때문이다.
올바른 데이터 흐름: 1. 유저가 UI(Widget) 상에서 상호작용 버튼 클릭 2. 위젯이 자신을 소유한 OwningPlayerController나 캐릭터에게 "유저가 이런 요청을 보냈다"고 전달 (UI ➔ Local Client Actor) 3. 캐릭터 혹은 플레이어 컨트롤러가 서버 RPC를 호출하여 서버에 판정 요청 (Client ➔ Server RPC) 4. 서버가 판정 후 데이터를 바꾸면, 그 데이터가 다시 클라이언트로 복제되어 UI가 바뀜 (Server ➔ Client Replication ➔ UI 갱신)
"UI 개발을 위한 아키텍처 가이드"
1단계: UI 위젯은 오직 GameState와 PlayerState만 바라보기 (Lyra 정석 원칙)
2단계: 오너십(GetOwningPlayer) 철저히 준수하기
3단계: UI에서의 모든 조작은 "요청(PC/Character 통로)"으로 보내기
2. 지난 코드 리뷰 및 피드백
UI 기능을 C++로 구현해놓고 에디터에서 화면을 구성하는 방식을 만들기 위해 지난 코드를 먼저 분석해보았다.
<이미 구현되어 있는 기능>
체력바 표시 : IHealthInterface에 GetHP(), GetMaxHP() 등이 정석대로 분리 정의되어 있음.
점수 표시 : UTeamScoreComponent(TeamScore)와 UPlayerStatComponent(PersonalScore, ComboCount)가 복제 세팅되어 있음.
조준점 & 상호작용 프롬프트 : UParcelInteractionComponent에서 0.1초마다 시선 검사(CheckTraceTarget)를 수행하며 CurrentFocusedActor를 관리함.
미션 목표 및 진행 상황 : UTeamScoreComponent 내부에서 서버 타이머 기준 RemainingTime을 클라이언트에 동기화 중.
무기 및 소지 아이템 정보 표시 : 현재 프레임워크 핵심 코드에는 무기 및 탄약 시스템이 반영되어 있지 않음.
히트마커 / 데미지 표시 : IHealthInterface::TakeDamage와 OnDeath 판정만 존재하며, 타격자(공격자)에게 피드백을 주는 역방향 통로가 없음.
<보완해야 하는 사항>
체력바 표시 : 체력이 바뀔 때 UI에 신호를 줄 멀티캐스트 델리게이트(FOnHPChangedSignature)가 필요. 캐릭터나 PlayerState에서 체력이 깎이거나 복제될 때 이 이벤트를 브로드캐스트 해줘야 UI가 갱신됨.
점수 표시 : OnRep_TeamScore() 시점에 UI가 구독할 수 있는 동적 델리게이트(FOnScoreChangedSignature)를 브로드캐스트 하도록 매핑이 필요.
조준점 & 상호작용 프롬프트 : 단순 조준점은 화면 중앙에 그리면 되지만, 상자를 조준했을 때 크로스헤어가 바뀌거나 "E키를 눌러 들기" 같은 텍스트를 띄우려면, 조준 대상이 바뀔 때 호출될 델리게이트(FOnFocusedActorChanged)가 필요함.
미션 목표 및 진행 상황 : OnRep 함수나 델리게이트를 통해 UI의 텍스트 블록에 매칭.
무기 및 소지 아이템 정보 표시 : 캐릭터가 소유하는 관련 변수를 복제(Replicated)로 들고 있게 하고 변경 델리게이트를 열어달라고 요청해야 함.
히트마커 / 데미지 표시 : 데미지를 입거나 입었을 때, 서버가 연산한 후, 내 로컬 화면에만 피드백을 줘야 하므로, Client RPC 통로(Client_NotifyHit, Client_NotifyKill)가 PlayerController나 캐릭터에 구현되어야 함.
3. 작업한 내용들
[필요한 UI 기능 나열]
1. 인게임 HUD 화면 구성 및 백엔드 매핑
① 상단 전광판 영역 (전역 게임 상태 레이어)
모든 플레이어가 공통으로 보게 되는 팀 단위 전역 정보 영역. 클라이언트 화면에서 이 정보를 그릴 때는 GameState를 바라보아야 한다.
제한 시간 (남은 시간): AParcelGameState에 부착된 UTeamScoreComponent 내부의 RemainingTime을 동기화받아 텍스트 블록에 표시한다.
현재 팀 점수 / 필요한 점수 (목표 점수): UTeamScoreComponent 내부의 TeamScore 변수가 복제되는 시점(OnRep_TeamScore)에 델리게이트를 통해 전광판 텍스트를 갱신한다. 목표 점수 역시 GameState로부터 가져온다.
현재 스테이지 / 레벨(맵) 이름: 전역 매치 흐름을 관리하는 GameState에 저장된 정보를 안전하게 가져와 매핑한다.
② 하단 개인 상태창 영역 (로컬 플레이어 레이어)
"지금 내 화면을 보고 있는 주인이 누구인가?"를 명확히 판별해야 하므로, 반드시 GetOwningPlayerPawn() 또는 GetOwningPlayerState()를 사용하여 로컬 플레이어의 데이터 컴포넌트를 파헤쳐야 한다. (UGameplayStatics::GetPlayerController(0) 사용 금지).
캐릭터의 체력: 캐릭터 본체 혹은 상태를 들고 있는 IHealthInterface 구현체의 HP가 바뀔 때 브로드캐스트될 멀티캐스트 델리게이트(FOnHPChangedSignature)를 구독하여 체력바(ProgressBar)를 갱신한다.
캐릭터의 스태미나: 캐릭터의 이동 상태를 담당하는 컴포넌트(예: MovementStatComponent)에서 스태미나 값을 Replicated 변수로 열어두고 변경 이벤트를 바인딩한다.
플레이어의 점수 (개인 점수): AParcelPlayerState 내부의 UPlayerStatComponent가 관리하는 PersonalScore 변수가 복제되는 타이밍을 구독하여 개인 HUD 성적표를 실시간으로 갱신한다.
멀티플레이 중인 다른 플레이어의 아이디: PlayerState 배열(GameState->PlayerArray)을 순회하며 각 플레이어의 이름(PlayerName)을 HUD 스코어보드나 팀원 리스트에 매칭한다.
③ 화면 중앙 조준 및 상호작용 영역 (동적 피드백 레이어)
조준점 (크로스헤어): HUD 위젯의 정중앙에 기본 배치하되, 3인칭 시점의 레이저 조준 범위를 가이드해 준다.
상호작용 'E' 텍스트 & 조준점 손 모양 변경: 플레이어의 눈 역할을 하는 UParcelInteractionComponent에서 0.1초마다 시선 검사(CheckTraceTarget)를 수행한다. 이때 조준 대상이 감지되거나 바뀔 때 호출될 델리게이트(FOnFocusedActorChanged)를 UI 위젯이 구독하고 있다가, 대상이 상자라면 "E키를 눌러 들기" 프롬프트를 띄우고 조준점 아이콘을 손 모양 이미지로 동적 변경한다.
④ 들고 있는 택배 정보 영역 (운송 가이드 레이어)
현재 들고 있는 박스의 종류 & 남은 내구도: 플레이어의 양손 역할을 하는 UCharacterCarryComponent에 보관된 현재 상자(CarriedBox) 포인터에 접근한다. 상자 액터(ADeliveryBox) 내부의 목적지 태그, 박스 종류 데이터, 그리고 파손 판정 매니저와 연동되는 내구도/체력 정보를 읽어와 아이콘과 게이지(Bar) 형태로 화면에 띄워준다.
⑤ 연출 및 피드백 레이어 (이벤트 오버레이)
캐릭터 현재 움직임 상태 (정지/달리기 그림): 캐릭터의 가속도 정보나 Character.State.Sprint 같은 Gameplay Tag Container를 로컬에서 확인하여 위젯 애니메이션이나 상태 이미지를 스위칭한다.
플레이어가 데미지를 입었을 때의 피드백 (피격 연출): 로컬 캐릭터의 체력 변화 이벤트(FOnHPChangedSignature)에서 이전 HP보다 현재 HP가 낮아진 순간을 포착하여 화면 가장자리에 붉은색 혈흔 효과(Vignette Flash)를 트리거한다.
2. 추가하면 좋은 필수 UI 기능
택배 배송 FPS의 게임성과 재미 요소를 극대화하기 위해 백엔드에 이미 설계되어 있는 아래 두 가지 요소는 무조건 HUD에 추가하는 것이 좋아보임.
연속 배송 콤보 카운트 (Combo Display):
점수 시스템의 핵심 기믹으로, 콤보에 따라 보너스 점수가 증가하도록 이미 구현되어 있음. 배송 성공 시마다 화면에 "3 COMBO!"와 같이 팝업 연출을 띄워주고, 파손이나 배송 실패 시 UPlayerStatComponent->OnDeliveryFail()과 연동되어 콤보가 깨지는 시각적 피드백을 주기로 함.
게임진행 상태 알림 (Game Phase Notification Overlay):
게임 진행 상태가 Enum/태그(EGamePhase: Waiting, Playing, TimeOver, Result 등)로 명확히 관리됨. 제한 시간이 끝났을 때 화면 중앙에 크게 "TIME OVER" 텍스트를 연출하거나, 스테이지 클리어 시 "STAGE CLEAR" 오버레이를 띄우기 위한 연출용 위젯 레이어가 뼈대 구조에 포함되어야함. 게임 진행 상태에 따라 극적인 변화가 있으면 Tag 시스템으로 변경하도록 제안하겠지만, 단순 결과 창 목적이므로 Enum 형식으로 사용하기로 함.
*개인 프로젝트 및 개인 공부*
1. Github Desktop을 이용해서 프로젝트를 체계적으로 진행하기로 결정. 먼저 로컬 레포지토리를 만들고, github에 원격 레포지토리를 만들어 연결했음.
2. 메인 브랜치에서 깃 설정을 진행. .gitignore(vs, rider, unrealengine, windows)과 .gitattributes(uasset, umap, png, jpg, jpeg, exe, dll, pdb, cpp, h, cs, ini, uproject) 설정을 미리 진행. 이후 DGA 프로젝트 최상위 폴더에 가서 Git Bash를 열고, git init(깃 저장소로 지정), git status(상태 확인)를 입력. Untracked files가 화면에 나타남을 확인했음.
다음 단계로 깃허브 동기화를 위해 'git add .' 명령어 사용. 현재 폴더의 모든 파일을 이번 버전에 기록.
이후 git commit -m "Initialize DGA Project: Framework and Subsystem Architecture base" 명령어를 입력해서 메모를 남겨놓고(최초의 깃 버전), 깃허브 사이트에 만들어두었던 레포지토리 Https 주소를 복사해서 git remote add origin.. 명령어를 사용해서 깃허브 원격 주소를 등록하였음.
만지다가 꼬였는지. "remote origin already exists" 라고 뜨는 것을 확인. 아마 이전에 잘못 git 폴더를 지정했던 탓인 듯 함. 따라서 git remote set-url origin ... 명령어로 새 주소로 강제 업데이트를 진행했음.
이후 git branch -M main 명령어로 브랜치 이름을 main으로 고정시켰고, git push -u origin main --force 명령어로 Push를 진행.
3. 이후 main을 기반으로 한 dev 브랜치를 새로 만듬. 기능별로 브랜치를 나누어 작업하고, 버전 관리 및 디버깅의 용이함을 위해 feat/기능 식으로 브랜치를 분리하여 작업하기로 결정. 혼자 작업하지만 나중에 혹시 모를 버그를 잡기 위해서는 반드시 이 과정이 필요하다고 생각되었음. 먼저 전역매니저 및 서브시스템 작업을 위해 feat/Subsystem 브랜치를 dev 브랜치 기반으로 새롭게 만듬.
*오늘의 총평*
기본에 충실한 하루였다. 팀프로젝트를 위한 멀티플레이 UI 구현을 위한 기본기를 다지는 시간이었다.