TIL
07.22 TIL
2026. 7. 22. 20:25

일정표

시간 할 일 비고
08:00~10:00 코드카타, 팀프로젝트  
10:00~13:00 팀프로젝트, 개인프로젝트  
14:00~18:00 팀프로젝트  
19:00~21:00 팀프로젝트  
21:00~22:00 운동  
22:00~23:30 개인 프로젝트  


*오늘의 코드카타*

 

문제. 대충 만든 자판

더보기

휴대폰의 자판은 컴퓨터 키보드 자판과는 다르게 하나의 키에 여러 개의 문자가 할당될 수 있습니다. 키 하나에 여러 문자가 할당된 경우, 동일한 키를 연속해서 빠르게 누르면 할당된 순서대로 문자가 바뀝니다.
예를 들어, 1번 키에 "A", "B", "C" 순서대로 문자가 할당되어 있다면 1번 키를 한 번 누르면 "A", 두 번 누르면 "B", 세 번 누르면 "C"가 되는 식입니다.
같은 규칙을 적용해 아무렇게나 만든 휴대폰 자판이 있습니다. 이 휴대폰 자판은 키의 개수가 1개부터 최대 100개까지 있을 수 있으며, 특정 키를 눌렀을 때 입력되는 문자들도 무작위로 배열되어 있습니다. 또, 같은 문자가 자판 전체에 여러 번 할당된 경우도 있고, 키 하나에 같은 문자가 여러 번 할당된 경우도 있습니다. 심지어 아예 할당되지 않은 경우도 있습니다. 따라서 몇몇 문자열은 작성할 수 없을 수도 있습니다.
이 휴대폰 자판을 이용해 특정 문자열을 작성할 때, 키를 최소 몇 번 눌러야 그 문자열을 작성할 수 있는지 알아보고자 합니다.
1번 키부터 차례대로 할당된 문자들이 순서대로 담긴 문자열배열 keymap과 입력하려는 문자열들이 담긴 문자열 배열 targets가 주어질 때, 각 문자열을 작성하기 위해 키를 최소 몇 번씩 눌러야 하는지 순서대로 배열에 담아 return 하는 solution 함수를 완성해 주세요.
단, 목표 문자열을 작성할 수 없을 때는 -1을 저장합니다.

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

using namespace std;

vector<int> solution(vector<string> keymap, vector<string> targets) {
    vector<int> answer;
    answer.reserve(targets.size());

    vector<int> min_press(26, -1);

    for (const string& key : keymap) {
        for (int i = 0; i < key.length(); ++i) {
            char c = key[i];
            int idx = c - 'A';
            int count = i + 1;

            if (min_press[idx] == -1 || count < min_press[idx]) {
                min_press[idx] = count;
            }
        }
    }

    for (const string& target : targets) {
        int total_press = 0;
        bool possible = true;

        for (char c : target) {
            int idx = c - 'A';

            if (min_press[idx] == -1) {
                possible = false;
                break;
            }

            total_press += min_press[idx];
        }

        if (possible) {
            answer.push_back(total_press);
        } else {
            answer.push_back(-1);
        }
    }
    
    return answer;
}

1) 알파벳별 최소 클릭 횟수 사전(Map/Array) 만들기

알파벳 A부터 Z까지 각 문자를 입력하기 위해 눌러야 하는 최소 횟수를 저장할 min_press 테이블을 준비한다.

keymap의 모든 키와 문자를 순회하면서, 어떤 문자가 몇 번째 위치(클릭 횟수 = index + 1)에 처음 나오는지 혹은 이미 기록된 클릭 횟수보다 더 적게 누를 수 있는지 비교하여 최솟값으로 갱신한다.

 

2) 목표 문자열(targets)의 총 클릭 횟수 계산

targets에 있는 각 단어의 알파벳들을 하나씩 확인한다.

해당 알파벳이 min_press 테이블에 존재한다면 클릭 횟수를 누적(sum += min_press[c])한다.

만약 단 하나의 알파벳이라도 min_press 테이블에 등록되어 있지 않다면(자판 어디에서도 만들 수 없다면), 해당 단어는 작성 불가능하므로 -1을 저장한다.

C++에서는 알파벳이 모두 대문자(A~Z)로 주어지므로 std::map이나 unordered_map 대신 크기 26 또는 128짜리 배열(또는 vector)을 사용하면 메모리와 속도 면에서 훨씬 효율적이다.


*팀 프로젝트*

1. 작업한 내용들

 

[1. 상자 충돌 시 데미지, 래그돌, 카메라 쉐이크]

ADeliveryBox::OnPhysicsHit 함수 내에 충돌 속도 및 자해 방지(bIsSelfHit) 검증, 체력 차감(TakeDamage), 상자 떨어뜨리기(ForceDropByTrap) 로직이 잘 구현되어 있으므로, DeliveryBox.cpp 파일에 URagdollComponent 헤더 포함 및 래그돌 트리거 코드만 추가했음.

 

다만 이렇게 코드만 추가하니까 실제로는 체력이 닳지 않고 래그돌 상태로 전환이 되질 않았음.

 

 

OnPhysicsHit 최상단 무적 리턴문 제거 및 구조 분리

 

기존 문제: 함수 맨 첫 줄의 if (IsInvulnerable()) return;으로 인해 상자가 스폰 초기/무적 상태일 때 함수 전체가 종료되어 아래쪽 플레이어 피격, 체력 차감, 넉백, 래그돌 실행문까지 도달하지 못했던 문제를 해결.

 

수정 내용: 상자가 무적 상태이더라도 "던져진 상자가 플레이어에게 피해를 주고 래그돌을 터뜨리는 연산"은 항상 가동되도록 구조를 분리. IsInvulnerable() 검사는 상자 본인의 체력/파손 연산(EvaluateImpact)에만 적용.

 

피격 판정 임계값 현실화 및 속도 보정

 

기존 문제: 충돌 속도 임계값이 150.f로 너무 크게 잡혀 있어 빗맞거나 캡슐 콜리전에 의해 충격량이 흡수될 경우 래그돌이 발동하지 않음.

 

수정 내용: 실시간 상자 이동 속도(GetVelocity()) 보완 공식을 추가하고 피격 임계값을 80.f로 조정하여 던진 상자에 플레이어가 맞으면 확정적으로 체력 차감 및 래그돌 상태로 전환되도록 보완.

 

손에 들고 있을 때 상자가 무적이 되는 현상 수정 (IsInvulnerable)

 

기존 문제: 기존 코드에 있던 "한 번도 주운 적 없는 상자는 스폰 후 10초간 무적" 예외 분기로 인해, 상자를 집어 들었을 때 무적이 유지되어 함정 피해를 받지 않았음.

 

수정 내용: 10초 예외 로직을 지우고 "스폰 후 0.5초 경과 시 들고 있든 바닥에 있든 무조건 무적 해제"로 통합하여, 0.5초 이후 상자를 들고 함정을 밟으면 상자 체력도 함께 차감되도록 정정.

 

 

<디버깅 추가>

 

원인 1 (조기 리턴 버그): ADeliveryBox::OnPhysicsHit에서 NormalImpulse로 연산한 VelocityChange < 50.0f 조건문이 상대 액터가 캐릭터(Pawn)인지 검사하기도 전 최상단에서 return;으로 함수를 조기 종료시키고 있었음.

언리얼 엔진의 캐릭터(Capsule Component)는 물리 시뮬레이션(RigidBody) 개체가 아니기 때문에, 물리 상자가 캐릭터에 부딪힐 때 NormalImpulse 수치가 0에 가깝게 측정되어 캐릭터 피격 판정 로직 전체가 실행되지 못했음.

 

원인 2 (클라이언트 동기화 지연): URagdollComponent::IsRagdoll() 함수가 오직 UParcelPlayerStateComponent의 GameplayTag 복제 여부만 검사하고 있었음. Multicast_SetRagdoll을 통해 피격된 클라이언트 PC에서 래그돌 및 3인칭 카메라 모드(EnterRagdollCameraMode)가 켜져도, 태그 복제 패킷이 도달하기 전까지 몇 프레임 동안 IsRagdoll()이 false를 반환하여 TickComponent의 3인칭 카메라 연산이 스킵되는 현상이 있었음.

 

2. 상자 스폰 무적 시간(2초) 및 들기 시 무적 해제

 

원인: 기존 IsInvulnerable()은 스폰 후 0.5초 무적만 처리하고 있었으며, 플레이어가 집어 들었을 때 스폰 타이머 무적을 무력화하는 예외 처리가 부족했음.

수정: 스폰 무적 시간을 2.0초로 확대하되, 플레이어가 상자를 들었거나(Box.State.Held 태그 또는 HoldingCarrier != nullptr), 한 번이라도 들려진 적이 있다면(bHasBeenPickedUp == true) 스폰 무적 시간을 즉시 무효화(해제)하도록 정밀 보완. (단, 0.3초 다중 피격 방지 쿨타임은 유지되어 연쇄 파손을 막음)

 

[2. 미니 스튜디오 스켈레톤 메시 수정]

바뀐 캐릭터의 스켈레톤 메시로 수정하였음.

 

[3. UI 디자인 개선, 사운드 추가]

버튼을 누르는 사운드를 추가하였고, UI 디자인도 일부 개선하였음.

 

언리얼 엔진 UMG 및 C++에서 UI 사운드를 재생하는 가장 정석적이고 깔끔한 방법은 UGameplayStatics::PlaySound2D() 함수를 활용.

 

1. UI 버튼 클릭 사운드 적용하기

 

버튼을 눌렀을 때 반응하는 위젯들(UParcelMainMenuWidget, UParcelOptionsWidget, UParcelShopInventoryWidget 등)에 공통으로 적용하는 방식.

 

1) 헤더 파일 수정 (예: ParcelMainMenuWidget.h)

 

USoundBase 포인터 변수와 사운드 재생 헬퍼 함수를 추가

 

2) 소스 파일 수정 (ParcelMainMenuWidget.cpp)

 

#include "Kismet/GameplayStatics.h"와 #include "Sound/SoundBase.h"를 추가한 뒤, 각 핸들러 초입에서 PlayButtonClickSound()를 호출

 

OptionsWidget이나 ShopInventoryWidget 등 다른 위젯들도 동일하게 ButtonClickSound 변수를 추가하고 버튼 이벤트 핸들러마다PlayButtonClickSound()를 넣어 주었음.

 

2. 사망 카운트다운 심장소리 (Heartbeat Sound) 적용하기

 

5초 카운트다운 숫자가 1초마다 줄어들고 애니메이션 연출(K2_OnCountdownChanged)이 들어갈 때마다 두근거리는 소리를 재생하도록 구현.

 

1) 헤더 파일 수정 (ParcelInGameDeadHUDWidget.h)

 

HeartbeatSound 변수를 선언

 

2) 소스 파일 수정 (ParcelInGameDeadHUDWidget.cpp)

 

카운트다운 시작 시점(StartDeathCountdown)과 1초 주기 갱신 시점(AdvanceCountdown)에 소리를 출력

 

언리얼 에디터 설정 (마무리 단계)

 

코드 컴파일(Ctrl + Alt + F11 또는 라이브 코딩) 후 언리얼 에디터에서 사운드 에셋을 연결해 주어야 작동.

 

사망 위젯 에셋 설정 (WBP_InGameDeadHUD):

 

위젯 블루프린트에서 Class Defaults (클래스 디폴트) 버튼을 클릭.

 

우측 Details 패널의 Parcel UI -> Dead -> Sound 카테고리에서 Heartbeat Sound 슬롯에 임포트해 둔 심장소리 음원(.wav 또는 Sound Cue)을 드롭다운으로 선택.

 

메인 메뉴 위젯 에셋 설정 (WBP_MainMenu):

위젯 블루프린트의 Class Defaults에서 Main Menu -> Sound 카테고리의 Button Click Sound 슬롯에 버튼 딸깍하는 효과음을 할당.

 

 

[4. InGameHUD에 미니맵 부착]

AParcelCharacter 코드에 이미 USpringArmComponent와 USceneCaptureComponent2D가 세팅되어 있다. (로컬 플레이어만 활성화하고 원격 플레이어의 컴포넌트는 Destroy하여 성능 오버헤드를 막는 최적화 로직도 포함되어 있다.)

 

이제 이 MinimapCaptureComponent가 찍은 실시간 화면을 Render Target에 보낸 뒤, 이를 UI 머티리얼을 통해 WBP_InGameHUD에 띄워주면 된다.

 

1단계: C++ 코드 미세 보완 (AParcelCharacter)

 

MinimapCaptureComponent가 어떤 렌더 타겟에 화면을 그려야 할지 지정해 줄 UTextureRenderTarget2D 에셋 변수를 추가하고, UpdateMinimapCaptureState 시점에 연결한다.

 

 

Render Target 생성

콘텐츠 브라우저 우클릭 ➔ Materials & TexturesRender Target 선택.

 

이름을 RT_Minimap으로 저장.

 

RT_Minimap 더블 클릭 ➔ 디테일 창에서 Size X, Y를 512 x 512 (성능 우선 시 256 x 256)로 설정.

 

UI Material 생성 (원형 마스킹 UI)

 

RT_Minimap 우클릭 ➔ Create Material ➔ 이름 M_Minimap 저장.

 

M_Minimap 더블 클릭 ➔ 디테일 창에서 Material Domain을 User Interface로 변경 (Blend Mode: Translucent).

 

원형 마스킹 노드 연결:

 

RadialGradientExponential 노드 생성 (Radius = 0.5, Density = 10.0 정도).

 

RT_Minimap Texture Sample의 RGB 핀을 Final Color에 연결.

 

Radial Gradient의 Out 핀을 Opacity에 연결하여 원형으로 자른다.

 

캐릭터 블루프린트(BP_ParcelCharacter)에 Render Target 할당

 

BP_ParcelCharacter 열기 ➔ 디테일 패널의 Minimap 카테고리 이동.

 

RT_Minimap을 Minimap Render Target 항목에 할당

 

 

[5. 옵션 세팅 수정]

UParcelGameUserSettings는 게임 실행 중 메모리에 계속 남아있는 싱글톤(GameUserSettings) 객체이다.

 

게임 실행 후 최초 1회 볼륨 변경 시 bSettingsSoundMixPushed가 true가 된다.

 

이후 새로운 레벨로 이동하거나 PIE(에디터 실행)를 재시작하면, 언리얼 엔진은 월드의 오디오 디바이스(Audio Device) SoundMix 스택을 자동으로 초기화해 버린다.

 

하지만 싱글톤 객체 내부의 bSettingsSoundMixPushed는 계속 true로 유지되어 있기 때문에, 두 번째 호출부터는 PushSoundMixModifier가 불리지 않게 된다.

 

결과적으로 현재 월드에 SoundMix가 적용되지 않은 상태에서 SetSoundMixClassOverride만 실행되므로 사운드가 전혀 변하지 않는다.

따라서, ParcelGameUserSettings.cpp의 bSettingsSoundMixPushed 조건문 및 변수를 제거하고, ApplyMasterVolume 호출 시 항상 PushSoundMixModifier를 먼저 호출하도록 수정했다.

 

추가로 다음의 코드를 추가해서 볼륨 옵션 조절시의 편의성을 개선했다.

Settings->ApplyMasterVolume(GetWorld() ? GetWorld() : this);

 

[6. 스테이지 종료 시 결과창 HUD 출력]

 

서버 (UDeliveryRuleComponent):

 

제한시간 만료(OnTimeUp) 시 1초 매초 감점 타이머(RoundTimerHandle)를 정지.

 

점수 비율로 Grade(A/B/C/F) 및 보상 금액(Reward)을 계산하고 각 플레이어에게 지급.

 

10초 동안 플레이어가 자유롭게 움직일 수 있도록 조작을 차단하지 않고, 10초 후 이동 타이머(NextStageTimerHandle)를 동작.

 

GameState를 통해 전원에게 결과 패킷(Multicast_NotifyStageResult)을 브로드캐스트.

 

클라이언트 (UParcelHUDWidget):

 

결과를 전달받아 블루프린트 이벤트(K2_OnStageResultStarted)를 쏘아 오늘의 일당 UI 팝업 및 축하 사운드를 재생.

 

로컬 1초 타이머를 돌려 K2_OnTransitionCountdownUpdated 이벤트를 쏴주고, 화면의 안내 텍스트가 10초 뒤..., 9초 뒤...로 변동.

 

1. C++ 코드 수정

 

1-1. Core/ParcelGameState.h & ParcelGameState.cpp

 

서버에서 산정된 스테이지 결과를 모든 클라이언트에 동시 전파할 델리게이트와 RPC를 추가

 

Core/DeliveryRuleComponent.h & DeliveryRuleComponent.cpp

 

시간 만료 시 즉시 매치를 종료하지 않고 10초 여유 시간을 가지는 로직으로 보완

 

 

1-3. UI/ParcelHUDWidget.h & ParcelHUDWidget.cpp

 

HUD 위젯에서 이벤트를 받아 블루프린트로 넘겨주고, 카운트다운 숫자를 매초 계산해 출력

 

이후 블루프린트 이벤트 그래프에서 다음과 같이 노드를 연결하면 된다.

 

<작업 종합>

더보기

[Minimap]

  1. BP_ParcelCharacter에 Camera가 붙어서 꼭 필요합니다.
  2. 메인메뉴레벨의 경우 바뀐 스켈레탈 메쉬에 맞는 애니메이션을 삽입했습니다.
  3. character 코드는 경수님이 작성한 코드를 기반으로 멀티플레이 안정성을 위한 코드를 조금 추가했습니다.

[UserSettings]
OptionWidget이랑 UserSetting도 겸사겸사 수정했습니다. 이제 '슬라이드 바'를 움직일 때도 즉각 소리가 변하고, 적용버튼을 누르면 해당 소리 크기가 적용됩니다.

[StageResult]
스테이지 결과창을 추가했습니다. 스테이지 타이머가 0이 되면 10초의 대기 시간을 주고 Grade, 오늘의 일당, 10초 카운트 다운이 HUD에 출력됩니다. 10초 후 다음 스테이지로 이동합니다.

[스테이지 타이머 0초]

  1. [Server] UDeliveryRuleComponent::OnTimeUp()
    ├─① 감점 타이머(RoundTimer) 즉시 Clear (더 이상 점수 안 깎임)
    ├─② 최종 Grade(A/B/C/F) 및 오늘의 일당(Reward) 계산 & 지급
    ├─③ Multicast_NotifyStageResult() 패킷 전원 브로드캐스트
    └─④ NextStageTimerHandle 가동 (10.0초 대기 시동)

  2. [Client] UParcelHUDWidget::HandleOnStageResultReceived()
    ├─① K2_OnStageResultStarted 연출 트리거
    │ ├─ 결과 패널(Canvas_StageResult) Visible 전환
    │ ├─ 오늘의 일당 & Grade 텍스트 출력
    │ └─ SpawnSound2D 축하 음악 재생 ➔ 5초 후 FadeOut 정지

    ├─② 1초 주기 로컬 타이머 시동 (TransitionEndTime 계산)
    │ └─ K2_OnTransitionCountdownUpdated (10, 9, 8...) 텍스트 실시간 갱신

    └─③ 플레이어 입력을 차단하지 않음 (10초간 맵 자유 이동 보장)

  3. [10초 만료 시점]├─ [Client] 로컬 카운트다운 타이머 자동 종료 (0초)
    └─ [Server] UDeliveryRuleComponent::TransitionToNextStage() 호출└─ AGameMode::EndMatch() 실행 ➔ 다음 스테이지 맵 트래블 진행

 

void UParcelLobbyHUDWidget::HandleActionOrStartClicked()
{
    PlayButtonClickSound();
    
    APlayerController* PC = GetOwningPlayer();
    
    if (PC && PC->HasAuthority())
    {
        // 기본 폴백(Fallback) 맵 경로 설정
        FString SelectedMapPath = TEXT("/Game/Maps/LV_DF_Stage01");

        // MapDataTable에서 현재 선택된 인덱스의 MapPath 동적 추출
        if (MapDataTable)
        {
            TArray<FParcelMapStageData*> AllMapRows;
            MapDataTable->GetAllRows<FParcelMapStageData>(TEXT("StartGameMapContext"), AllMapRows);

            if (AllMapRows.IsValidIndex(LocalCurrentMapIndex) && AllMapRows[LocalCurrentMapIndex])
            {
                if (!AllMapRows[LocalCurrentMapIndex]->MapPath.IsEmpty())
                {
                    SelectedMapPath = AllMapRows[LocalCurrentMapIndex]->MapPath;
                }
                else
                {
                    UE_LOG(LogTemp, Warning, TEXT("[Lobby HUD] 선택된 Row(%d)의 MapPath가 비어있어 기본 맵으로 진행합니다."), LocalCurrentMapIndex);
                }
            }
            else
            {
                UE_LOG(LogTemp, Error, TEXT("[Lobby HUD] 선택된 맵 인덱스(%d)가 데이터 테이블 범위를 벗어났습니다"), LocalCurrentMapIndex);
            }
        }
        else
        {
            UE_LOG(LogTemp, Warning, TEXT("[Lobby HUD] MapDataTable이 할당되어 있지 않아 기본 맵 경로를 사용합니다."));
        }

        // Listen 서버 옵션(?listen) 결합
        FString TravelURL = FString::Printf(TEXT("%s?listen"), *SelectedMapPath);
        
        UE_LOG(LogTemp, Log, TEXT("[Lobby HUD] 방장 확정 ➔ 선택된 맵(%s)으로 강제 트래블(ServerTravel) 개시 URL: %s"), 
            *SelectedMapPath, *TravelURL);

        // 동적 경로로 ServerTravel 실행
        if (UWorld* World = GetWorld())
        {
            World->ServerTravel(TravelURL);
        }
    }
}

 

MapDataTable 동적 파싱: GetAllRows<FParcelMapStageData>를 통해 데이터 테이블 내 레벨 데이터를 조회하고, 현재 로비에서 선택된 LocalCurrentMapIndex 번째 레벨 데이터의 MapPath를 가져옵니다.

 

안전 구획 가드: MapDataTable이 누락되었거나 범위를 벗어난 인덱스 지정 시 크래시 없이 기본 스테이지(/Game/Maps/LV_DF_Stage01)로 진입하도록 방어 로직을 매립했습니다.

 

?listen 서버 패러미터 유지: 멀티플레이 세션 유지를 위해 가동되는 ?listen 쿼리 스트링을 동적 맵 경로 뒤에 자동으로 결합하도록 처리

 

 

 



*오늘의 총평*

좋은 하루였다. 팀프로젝트의 마지막날이라 생각하고 열심히 작업하고 디버깅했다.

'TIL' 카테고리의 다른 글

07.24 TIL  (0) 2026.07.24
07.23 TIL  (0) 2026.07.23
07.21 TIL  (0) 2026.07.21
07.20 TIL  (0) 2026.07.20
07.19 TIL  (0) 2026.07.19