Sparta Unreal 부트캠프 93일차

정찬호·2026년 4월 14일

코딩 테스트

프로그래머스 - 무인도 여행

방문 기록을 저장하여 중복해서 방문하는 것을 막았습니다.
모든 칸을 시작점으로 해서 DFS를 돌리며 방문한 칸, X이면 깊이 탐색을 멈추고 지금까지 축적된 값을 반환해 나가는 방식으로 연결된 모든 칸에 대한 합 연산을 진행했습니다.

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

using namespace std;

vector<pair<int,int>> dir = {
    {0, 1}, {0, -1}, {1, 0}, {-1, 0}
};

int DFS(const vector<string>& maps, vector<vector<bool>>& visited, pair<int,int> startPos)
{
    if(maps[startPos.first][startPos.second] == 'X' || visited[startPos.first][startPos.second] == true)
    {
        return 0;
    }
    
    int value = maps[startPos.first][startPos.second] - '0';
    visited[startPos.first][startPos.second] = true;
    for(int i = 0; i < 4; i++)
    {
        pair<int,int> newPos = 
            make_pair(startPos.first + dir[i].first,
                     startPos.second + dir[i].second);
        if(newPos.first < 0 || newPos.first >= maps.size())
        {
            continue;
        }
        else if(newPos.second < 0 || newPos.second >= maps[0].length())
        {
            continue;
        }
        value += DFS(maps, visited, newPos);
    }
    
    return value;
}

vector<int> solution(vector<string> maps) {
    vector<int> answer;
    
    vector<vector<bool>> visited(maps.size(), vector<bool>(maps[0].length(), false));
    
    for(int i = 0; i< maps.size(); i++)
    {
        for(int j = 0; j < maps[i].length(); j++)
        {
            int value = DFS(maps, visited, make_pair(i, j));
            if(value != 0)
            {
                answer.push_back(value);
            }
        }
    }
    
    if(answer.empty())
    {
        answer.push_back(-1);
    }
    else
    {
        sort(answer.begin(), answer.end());
    }
    return answer;
}

체점 결과


테스트 1 〉	통과 (0.01ms, 4.19MB)
테스트 2 〉	통과 (0.01ms, 4.22MB)
테스트 3 〉	통과 (0.02ms, 4.15MB)
테스트 4 〉	통과 (0.02ms, 4.21MB)
테스트 5 〉	통과 (0.06ms, 3.65MB)
테스트 6 〉	통과 (0.09ms, 4.22MB)
테스트 7 〉	통과 (0.07ms, 4.14MB)
테스트 8 〉	통과 (0.14ms, 4.21MB)
테스트 9 〉	통과 (0.28ms, 4.21MB)
테스트 10 〉	통과 (0.20ms, 4.2MB)
테스트 11 〉	통과 (0.18ms, 4.2MB)
테스트 12 〉	통과 (0.30ms, 4.21MB)
테스트 13 〉	통과 (0.28ms, 4.21MB)
테스트 14 〉	통과 (0.39ms, 4.22MB)
테스트 15 〉	통과 (0.39ms, 4.14MB)
테스트 16 〉	통과 (0.68ms, 3.94MB)
테스트 17 〉	통과 (0.02ms, 4.2MB)
테스트 18 〉	통과 (0.50ms, 4.15MB)
테스트 19 〉	통과 (0.47ms, 3.78MB)
테스트 20 〉	통과 (0.07ms, 4.21MB)
테스트 21 〉	통과 (0.09ms, 4.2MB)
테스트 22 〉	통과 (0.01ms, 4.21MB)
테스트 23 〉	통과 (1.31ms, 4.67MB)
테스트 24 〉	통과 (0.40ms, 4.21MB)
테스트 25 〉	통과 (0.02ms, 4.04MB)

UE5 멀티플레이어 미션 기믹 시스템 구현

오늘 한 일

언리얼 엔진 5 기반 멀티플레이어 프로젝트에서 미션 시스템의 기믹(Gimmick) 관련 코드를 리팩토링하고, Lever와 Chest 기믹의 동작을 구현했습니다.


1. 아이템 탐색 코드 중복 제거 (리팩토링)

문제

GimmickChest::CanInteractWithGimmickAltar::HasMatchingItemInHands가 완전히 동일한 패턴의 아이템 탐색 코드를 각자 가지고 있었습니다. 양손 슬롯을 순회하며 ItemTypeTag를 비교하는 로직이 두 곳에 중복되어 있었고, GimmickAltar::TryPlaceItemToSlot에서도 같은 순회가 한 번 더 반복되고 있었습니다.

해결

GimmickBase에 공통 헬퍼 함수 FindMissionItemByTag를 추가했습니다. 초기화 목록({ LeftHandItem, RightHandItem })을 활용해 두 슬롯을 한 번에 순회하고, 조건에 맞는 첫 번째 AVGMissionItemBase*를 반환합니다. 없으면 nullptr을 반환해 CanInteractWith에서는 != nullptr 체크로, TryPlaceItemToSlot에서는 바로 캐스팅 없이 사용할 수 있게 되었습니다.

// VGMissionGimmickBase.h
AVGMissionItemBase* FindMissionItemByTag(
    UVGEquipmentComponent* EquipComp,
    FGameplayTag RequiredTag) const;

// VGMissionGimmickBase.cpp
AVGMissionItemBase* AVGMissionGimmickBase::FindMissionItemByTag(
    UVGEquipmentComponent* EquipComp, FGameplayTag RequiredTag) const
{
    if (!EquipComp) return nullptr;

    for (AVGEquippableActor* HandItem : { EquipComp->LeftHandItem, EquipComp->RightHandItem })
    {
        if (!HandItem || !HandItem->EquipmentData) continue;

        UVGMissionItemDataAsset* ItemData =
            Cast<UVGMissionItemDataAsset>(HandItem->EquipmentData);

        if (ItemData && ItemData->ItemTypeTag == RequiredTag)
            return Cast<AVGMissionItemBase>(HandItem);
    }
    return nullptr;
}

이후 GimmickChest::CanInteractWithGimmickAltar의 중복 코드를 헬퍼 한 줄로 교체했습니다.


2. MissionItemBase — 슬롯이 가득 찼을 때 줍기 차단

문제

장착 슬롯이 양손 모두 가득 찬 상태에서도 Carry 아이템과 상호작용이 가능했습니다. 이 경우 아이템이 Carried 상태로 변경되지만 실제로 장착은 되지 않아, 해당 아이템이 영구적으로 사용 불가 상태가 되는 버그가 발생했습니다.

해결

VGMissionItemBase::CanInteractWith에 슬롯 여유 체크를 추가했습니다.

bool AVGMissionItemBase::CanInteractWith(AActor* Interactor) const
{
    if (IsCarried()) return false;
    if (ItemStateTag != VigilantMissionTags::ItemInactive) return false;

    if (Interactor)
    {
        UVGEquipmentComponent* EquipComp =
            Interactor->FindComponentByClass<UVGEquipmentComponent>();
        if (EquipComp)
        {
            const bool bLeftFull  = (EquipComp->LeftHandItem  != nullptr);
            const bool bRightFull = (EquipComp->RightHandItem != nullptr);

            if (bLeftFull && bRightFull)
                return false;
        }
    }

    return Super::CanInteractWith(Interactor);
}

3. GimmickLever — 타임라인 애니메이션 구현

구현 내용

레버 손잡이 전용 메시(LeverMeshComponent)를 받침대 메시(MeshComponent)에 부착하고, UTimelineComponentInactivePitchActivePitch 사이를 보간해 레버가 올라가고 내려가는 애니메이션을 구현했습니다.

멀티플레이어 동기화는 서버에서 Toggle() 호출 시 상태를 변경하고 OnRep_GimmickStateTag()를 직접 호출해 서버 자신도 애니메이션을 재생하며, 클라이언트는 복제 콜백으로 자동 처리됩니다.

void AVGMissionGimmickLever::OnRep_GimmickStateTag()
{
	Super::OnRep_GimmickStateTag();
	
	PlayLeverAnimation();
}

void AVGMissionGimmickLever::PlayLeverAnimation()
{
	if (!LeverTimeline) return;
 
	if (GimmickStateTag == VigilantMissionTags::GimmickActive)
	{
		LeverTimeline->PlayFromStart();
	}
	else if (GimmickStateTag == VigilantMissionTags::GimmickInactive)
	{
		LeverTimeline->ReverseFromEnd();
	}
}

4. GimmickChest — 열림 애니메이션 + 디졸브 + 보상 생성 순서 제어

구현 내용

상자 기믹에서 가장 공들인 부분은 보상 생성 타이밍 제어였다. 자연스러운 순서는 다음과 같다.

상호작용 → 아이템 소모 → 뚜껑 열리는 중(Active)
  → 뚜껑 완전히 열림 → 보상 생성(Completed) → 디졸브 → 액터 제거

GimmickStateTag를 중간 상태 신호로 활용했습니다. Active는 "열리는 중"을 의미하고, 타임라인 트리거 역할도 겸합니다. Completed는 타임라인이 끝난 뒤 서버에서만 설정하며, MissionBase가 이를 구독해 보상 스폰을 실행하게 합니다.

단계처리 주체내용
OnInteractWith서버아이템 소모, SetStateTag(Active)
OnRep_GimmickStateTag (Active)서버+클라이언트콜리전 제거, 타임라인 시작
OnLidTimelineUpdate서버+클라이언트LidClosedRollLidOpenRoll 보간
OnLidTimelineFinished서버만SetStateTag(Completed), bChestOpened = true, StartDissolve()
OnRep_bChestOpened클라이언트StartDissolve()
OnDissolveTimerTick서버+클라이언트DissolveAmount 파라미터 0→1
SetLifeSpan(0.01f)서버만액터 제거 (클라이언트는 복제 소멸)

늦게 접속한 클라이언트 처리

OnRep_GimmickStateTag에서 Completed 수신 시 뚜껑을 열린 각도(LidOpenRoll)로 즉시 세팅해, 타임라인 없이도 올바른 상태를 표시합니다.


배운 점

ReplicatedUsing 함수명 불일치는 조용한 버그다.
헤더에서 ReplicatedUsing = OnRep_bChestOpened로 선언(제거된 내용)했는데 실제 함수를 OnRep_BChestOpened(대문자 B)로 구현하면 컴파일 에러 없이 통과하지만, 런타임에서 복제 콜백이 전혀 호출되지 않습니다. UHT가 이름으로 함수를 찾기 때문에 대소문자 하나가 기능 전체를 무력화합니다.

UTimelineComponent는 기반 클래스보다 각 기믹에 두는 것이 맞다.
기믹마다 보간하는 프로퍼티(위치, 회전, 머티리얼 파라미터)와 커브가 다르기 때문에 기반 클래스에서 추상화하기가 어렵다고 합니다. 기반 클래스에 올리면 타임라인이 필요 없는 기믹들도 컴포넌트를 소유하게 되어 오히려 비대해집니다.

profile
게임 개발 지망생입니다.

0개의 댓글