방문 기록을 저장하여 중복해서 방문하는 것을 막았습니다.
모든 칸을 시작점으로 해서 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)
언리얼 엔진 5 기반 멀티플레이어 프로젝트에서 미션 시스템의 기믹(Gimmick) 관련 코드를 리팩토링하고, Lever와 Chest 기믹의 동작을 구현했습니다.
GimmickChest::CanInteractWith와 GimmickAltar::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::CanInteractWith와 GimmickAltar의 중복 코드를 헬퍼 한 줄로 교체했습니다.
장착 슬롯이 양손 모두 가득 찬 상태에서도 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);
}
레버 손잡이 전용 메시(LeverMeshComponent)를 받침대 메시(MeshComponent)에 부착하고, UTimelineComponent로 InactivePitch ↔ ActivePitch 사이를 보간해 레버가 올라가고 내려가는 애니메이션을 구현했습니다.
멀티플레이어 동기화는 서버에서 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();
}
}
상자 기믹에서 가장 공들인 부분은 보상 생성 타이밍 제어였다. 자연스러운 순서는 다음과 같다.
상호작용 → 아이템 소모 → 뚜껑 열리는 중(Active)
→ 뚜껑 완전히 열림 → 보상 생성(Completed) → 디졸브 → 액터 제거
GimmickStateTag를 중간 상태 신호로 활용했습니다. Active는 "열리는 중"을 의미하고, 타임라인 트리거 역할도 겸합니다. Completed는 타임라인이 끝난 뒤 서버에서만 설정하며, MissionBase가 이를 구독해 보상 스폰을 실행하게 합니다.
| 단계 | 처리 주체 | 내용 |
|---|---|---|
OnInteractWith | 서버 | 아이템 소모, SetStateTag(Active) |
OnRep_GimmickStateTag (Active) | 서버+클라이언트 | 콜리전 제거, 타임라인 시작 |
OnLidTimelineUpdate | 서버+클라이언트 | LidClosedRoll → LidOpenRoll 보간 |
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는 기반 클래스보다 각 기믹에 두는 것이 맞다.
기믹마다 보간하는 프로퍼티(위치, 회전, 머티리얼 파라미터)와 커브가 다르기 때문에 기반 클래스에서 추상화하기가 어렵다고 합니다. 기반 클래스에 올리면 타임라인이 필요 없는 기믹들도 컴포넌트를 소유하게 되어 오히려 비대해집니다.