시스템을 처음 도입할 때 여러 선택지를 놓고 고민했던 기록. git 커밋 로그뿐 아니라 당시 설계 메모(세션 기록)까지 다시 뒤져서, 왜 그 방식을 택했는지 이유까지 정리했다.
해결하려는 문제: 몬스터가 죽을 때마다 재화를 드롭해야 하는데, 몹 하나당 드롭 개수가 여러 개일 수 있어서 방식에 따라 네트워크 비용이 크게 달라지는 상황이었다.
고려한 방법
방법 1 — 복제 액터 방식: 재화 하나당 실제 AActor를 서버에서 스폰하고 모든 클라이언트에 복제.
방법 2 — 서버 레지스트리 + 클라 로컬 비주얼: 서버는 복제 액터를 두지 않고 데이터(레지스트리)만 들고 있다가, 각 플레이어에게는 "너에게만 보이는" 프록시로 필요한 정보만 전달하고, 실제 눈에 보이는 비주얼은 클라이언트가 로컬로 만든다.
최종 선택: 방법 2
선택 이유: 몹당 드롭 개수가 늘어나도 서버 복제 비용이 액터 수만큼 늘지 않는다는 점이 가장 컸다. 그리고 이미 투사체 시스템이 ProjectileManagerComponent(레지스트리) + ReplicationProxy + ProjectileVisual(클라 로컬)이라는 똑같은 3단 구조로 구현되어 있어서, 새 패턴을 발명하는 대신 이미 검증된 패턴을 그대로 미러링하기로 했다.
구현 구조
[몬스터 처치] 서버
│
▼
UNSCurrencyDropSubsystem.RegisterDrop(종류, 등급, 수량, 위치...)
│ 서버 전용 레지스트리. ActiveDrops 맵에 데이터만 기록 (복제 액터 없음)
│ DropId 발급
│
▼
등록된 플레이어별 프록시(Proxies)에 전부 SendSpawnEvent
│
▼
ANSCurrencyReplicationProxy (플레이어당 1개, Owner-Only 복제)
│ Client_SpawnCurrency(Event) RPC로 "이 플레이어에게만" 전달
│
▼
ANSLocalCurrencyPickup 생성 (클라이언트 로컬, 서버에 복제 안 됨)
│ 실제로 화면에 보이는 비주얼. 로컬에서 오버랩 감지
│
▼
[플레이어가 주움] 클라 → 서버에 TryCollect(DropId) 요청
│
▼
서버가 ActiveDrops 기준으로 재검증
├─ 성공 → SendRemoveEvent → Client_RemoveCurrency → 로컬 픽업 제거, 재화 지급
└─ 실패 → SendRestoreEvent → Client_RestoreCurrency → 로컬 픽업 다시 보이게
결과
| Before (방법 1 가정) | After (방법 2 적용) | |
|---|---|---|
| 서버가 복제하는 액터 수 | 드롭 개수만큼 증가 | 플레이어당 프록시 1개로 고정 |
| 드롭 100개 발생 시 | 복제 액터 100개 관리 | ActiveDrops 맵 데이터 100줄 + 이벤트 전송만 |
| 새 드롭 타입 추가 | 매번 새로운 복제 구조 설계 | 같은 3단 구조 재사용(파츠, 회복 아이템) |
얻은 효과
| 항목 | 내용 |
|---|---|
| 성능 | 드롭 개수가 늘어나도 리플리케이션 비용이 "이벤트 데이터 크기 × 플레이어 수"로 고정. 액터 복제(Transform 등 매 틱 동기화)가 없어 대량 드롭 상황에서도 트래픽이 액터 수에 비례해 늘지 않음 |
| 유지보수 | 투사체 시스템과 동일 구조라 한쪽을 이해하면 다른 쪽도 바로 이해됨. "서버 데이터 문제"와 "클라 비주얼 문제"가 계층으로 분리돼 원인 추적이 쉬움 |
| 확장성 | 같은 패턴을 파츠 드롭(UNSDroppedPartRegistrySubsystem), 회복 아이템 드롭(UNSHealDropSubsystem)에도 재사용. 새 드롭 타입 추가 시 구조를 처음부터 고민할 필요 없음 |
| 협업 | 드롭 확률·수치는 DataTable로 분리해 디자이너가 직접 관리, 코드는 연결부만 담당 |
해결하려는 문제: 상호작용 반응 로직이 ANSInteractableActor(AActor) 하나에 고정돼 있었는데, 파츠 상점 NPC나 펫 NPC는 추후 AIController로 순찰·추적처럼 자율적으로 길을 찾아 움직여야 하는 대상이었다. AActor는 SetActorLocation 등으로 위치 자체는 옮길 수 있지만, Controller에게 Possess될 수 없고 내비게이션 이동 컴포넌트(UNavMovementComponent 계열)가 없어서 AI가 NavMesh 기반으로 자율 이동시키는 표준 경로를 쓸 수 없다. 이 기능은 APawn부터 생기고, ACharacter가 UCharacterMovementComponent까지 기본으로 갖춘 표준 클래스다. 즉 NPC는 ACharacter를 상속해야 했는데, 언리얼은 다중 상속이 안 되므로 AActor 기반의 기존 상호작용 클래스를 그대로 상속하면서 동시에 ACharacter를 상속할 수 없었다.
고려한 방법
방법 1 — AActor 상속 구조를 유지: 상호작용 로직을 계속 ANSInteractableActor에 두고, NPC 쪽은 별도로 처리.
방법 2 — 인터페이스로 기능만 분리: 상호작용에 필요한 동작(CanInteract, OnInteract, 프롬프트 정보)을 INSInteractable 인터페이스로 빼서, NPC는 ACharacter를 상속하면서 이 인터페이스만 구현.
ACharacter)과 정적인 대상(AActor) 모두 같은 인터페이스로 상호작용 가능.ANSInteractableActor를 상속하던 코드를 인터페이스 구현으로 다시 작성해야 함.최종 선택: 방법 2
선택 이유: NPC는 AIController에게 Possess되어 자율 이동해야 하니 ACharacter(→APawn)가 필수인데, 다중 상속이 안 되는 언리얼 구조에서는 "기능"을 상속이 아니라 인터페이스로 떼어내는 것만이 유일한 해법이었다.
구현 구조
[Before]
ANSInteractableActor (AActor)
└─ OnInteract 로직이 이 클래스에 고정
└─ NPC는 AIController에게 Possess되어 자율 이동해야 함 → APawn/ACharacter 필요 → 다중상속 불가 → 재사용 불가
[After]
INSInteractable (인터페이스: CanInteract / OnInteract / GetPromptText / ...)
├─ ANSInteractableNPCBase : ACharacter, INSInteractable (이동 가능 NPC)
├─ ANSDroppedPart : AActor, INSInteractable (드롭 파츠)
└─ ANSInteractableActor : AActor, INSInteractable (정적 프롭 전용으로 범위 축소)
UNSInteractionComponent → INSInteractable::Execute_OnInteract(Target, PC)
(대상이 뭔지 몰라도 인터페이스만 보고 호출)
결과
| Before | After | |
|---|---|---|
| NPC 추가 시 | ACharacter 필요 → 기존 상호작용 베이스 재사용 불가 | INSInteractable만 구현하면 됨 |
| 상호작용 컴포넌트 코드 | 대상 타입별 분기 필요 | 인터페이스 호출 하나로 통일 |
| 정적 프롭 | 상호작용 로직과 뒤섞임 | ANSInteractableActor로 역할 축소, 여전히 인터페이스 구현 |
얻은 효과
| 항목 | 내용 |
|---|---|
| 성능 | 직접적 영향 없음 — 구조적 결정 |
| 유지보수 | 새 NPC/오브젝트를 추가할 때 상속 트리를 고민할 필요 없이 인터페이스 구현 여부만 확인하면 됨 |
| 확장성 | 파츠 상점 NPC, 펫 NPC, 캐릭터 선택 NPC, 드롭 파츠까지 전부 같은 인터페이스로 확장됨 |
| 협업 | 상호작용 로직 담당자가 대상의 내부 구현을 몰라도 인터페이스 계약만으로 연동 가능 |
해결하려는 문제: 인터페이스로 기능은 분리했지만, "누가 상호작용 후보를 감지하고 판정하는가"는 별도로 정해야 했다.
고려한 방법
방법 1 — 대상 액터(NPC/드롭 파츠)에 감지 컴포넌트 부착
방법 2 — PlayerController에 부착
방법 3 — 캐릭터(Pawn)에 부착 (최종)
IsLocallyControlled()로 로컬/원격 구분도 명확.최종 선택: 방법 3
선택 이유: 실제로 감지 컴포넌트를 대상 액터 → 컨트롤러 → 캐릭터 순서로 세 번 옮겨본 뒤에야 확정했다. 컨트롤러는 리슨서버에서 클라마다 다르게 존재하지만, 판정 로직은 캐릭터(폰) 기준이 자연스럽다는 걸 직접 붙여보고 나서 알게 됐다.
구현 구조
[시도 1] 대상 액터(NPC/파츠) ← 상호작용 컴포넌트 부착
→ NPC/파츠마다 감지 로직 중복, 대상이 늘어날수록 반복 비용 증가
[시도 2] PlayerController ← 상호작용 컴포넌트 부착
→ 리슨서버에서 컨트롤러가 클라마다 다르게 존재 → 로컬/원격 구분 애매
[최종] ACharacter(Pawn) ← UNSInteractionComponent 부착
→ EnableLocalInteraction()에서 IsLocallyControlled() 확인 후에만 감지 활성화
→ 판정 주체(플레이어)와 감지 위치가 일치
결과
| Before (시도 1~2) | After (최종) | |
|---|---|---|
| 감지 로직 위치 | 대상별 중복 또는 컨트롤러 종속 | 캐릭터 하나에 통일 |
| 리슨서버 로컬/원격 구분 | 애매함 (실제로 위젯 누출 버그로 드러남) | IsLocallyControlled()로 명확 |
| 대상 추가 비용 | 대상마다 감지 로직 추가 | 인터페이스만 구현, 감지는 그대로 |
얻은 효과
| 항목 | 내용 |
|---|---|
| 성능 | 오버랩 후보가 없으면 틱을 끄는 최적화를 캐릭터 컴포넌트 한 곳에서만 관리하면 됨 |
| 유지보수 | 리슨서버 관련 버그(호스트 화면에 클라 위젯 노출 등)가 났을 때 확인할 곳이 한 컴포넌트로 좁혀짐 |
| 확장성 | NPC/드롭 오브젝트 종류가 늘어도 캐릭터 쪽 감지 로직은 변경 없음 |
| 협업 | "상호작용이 이상하다"는 리포트가 오면 항상 같은 컴포넌트부터 확인하면 되는 관례가 생김 |
해결하려는 문제: 파츠를 장착하면 능력치가 올라야 하는데, 파츠 종류가 계속 늘어날 예정이라 "파츠 하나당 GameplayEffect 하나"로 가면 애셋이 선형으로 늘어날 게 뻔했다.
고려한 방법
방법 1 — 부위별로 다른 GAS 연동 방식 (초기 설계): 바디 파츠(체력↔실드 연동)는 MMC(ModifierMagnitudeCalculation)로 MaxHealth 기반 계산, 암/레그 파츠(단순 증가형)는 SetByCaller로 값 주입.
방법 2 — 공용 GE + StatTag 매핑 (최종): 파츠 종류와 무관하게 GE 하나(SharedPartEffectClass)를 두고, 이 GE에 모든 스탯의 Modifier를 미리 정의해둔 뒤, 파츠가 담당하는 스탯 하나만 SetByCaller로 값을 채움.
최종 선택: 방법 2. 다만 처음부터 방법 2로 시작한 게 아니라, 방법 1로 먼저 구현하고 파츠 수가 늘어나면서 애셋 관리 비용이 커지는 걸 실제로 겪은 뒤 방법 2로 다시 통합했다.
선택 이유: 파츠 종류가 늘어날수록 개별 GE 방식의 관리 비용이 선형으로 커진다는 게 실제로 드러났기 때문이다. 처음부터 정답을 맞히기보다, 실제 파츠 수가 늘어났을 때의 비용을 겪어보고 통합하는 순서를 밟았다.
구현 구조
[Before] 파츠 A → GE_PartA (Modifier: StatA)
파츠 B → GE_PartB (Modifier: StatB)
파츠 N → GE_PartN (...) ← 파츠 종류만큼 GE 애셋 증가
[After] 파츠 A/B/N → SharedPartEffectClass (GE 1개, 모든 스탯 Modifier 내장)
│
▼
NSCombatStatAttribute::InitializeNeutralSetByCallers(Spec) 모든 칸을 중립값(Add=0, Multiply=1)으로 채움
│
▼
StatTag → 매핑 테이블에서 이 파츠가 채울 SetByCaller 태그 결정
│
▼
Spec.SetSetByCallerMagnitude(그 태그, Part->CurrentValue) 해당 칸만 실제 값으로 덮어씀
결과
| Before (개별 GE) | After (공용 GE) | |
|---|---|---|
| 파츠 10종 추가 시 | GE 애셋 10개 추가 필요 | GE 애셋 0개, StatTag만 지정 |
| 새 스탯 종류 추가 | 관련 GE들을 각각 수정 | 매핑 테이블 + 공용 GE 한 곳만 수정 |
| 관리 방식 | 부위별로 MMC/SetByCaller 두 갈래 | SetByCaller 방식으로 통일 |
얻은 효과
| 항목 | 내용 |
|---|---|
| 성능 | GE 애셋 수가 늘지 않아 로드/평가해야 할 GameplayEffect 종류가 고정됨 |
| 유지보수 | 능력치 적용 버그가 나면 "공용 GE 자체"와 "매핑 테이블" 두 곳만 확인하면 됨 |
| 확장성 | 새 파츠 추가가 "StatTag 지정 + 값 범위 DT 등록"으로 끝남, GE 신규 제작 불필요 |
| 협업 | 파츠 밸런스 담당자가 DT에 값만 넣으면 되고, GE 구조는 건드릴 필요 없음 |
해결하려는 문제: 파츠가 장착되는 부위(슬롯)를 어떻게 식별할지 정해야 했다. 초기엔 "Slot/Rarity는 enum 유지, 태그 불필요"로 결정했었는데, 슬롯 종류가 데이터 주도로 늘어날 필요가 생겼다.
고려한 방법
방법 1 — enum (초기 결정): ENSPartSlot { Body, Arm, Leg } 같은 고정 열거형.
방법 2 — GameplayTag (전환 후): FGameplayTag Slot, 실제 슬롯 목록은 DataTable 행으로 정의.
최종 선택: 방법 2
선택 이유: 슬롯 개수/종류가 데이터 주도로 늘어날 필요가 생기면서, enum은 늘어날 때마다 재컴파일이 필요하다는 제약이 계속 걸림돌이 됐다.
구현 구조
[Before] enum ENSPartSlot { Body, Arm, Leg } ← 새 슬롯 추가 시 코드 수정 + 재컴파일
│
▼
UI가 enum 값을 하드코딩으로 순회 → 슬롯 위젯 3개 고정 생성
[After] FGameplayTag Slot (DT_PartSlot 행 = 슬롯 정의) ← 데이터 추가만으로 슬롯 증가
│
▼
DataSubsystem::GetAllSlotRows() 순회 → 슬롯 수만큼 위젯 동적 생성
결과
| Before (enum) | After (GameplayTag) | |
|---|---|---|
| 슬롯 추가 | 코드 수정 + 재컴파일 | DT에 행 추가 |
| UI 슬롯 목록 | 하드코딩 순회 | DT 기반 동적 생성 |
| 장착/해제 판정 로직 | enum 값 직접 비교 | 태그 매칭 |
얻은 효과
| 항목 | 내용 |
|---|---|
| 성능 | 태그 비교는 enum 비교보다 약간 비용이 있지만, 슬롯 수가 적어 체감 차이는 없음 |
| 유지보수 | 슬롯 관련 로직이 "슬롯이 몇 개인지 몰라도" 동작하도록 바뀜 |
| 확장성 | 새 슬롯 추가가 순수 데이터 작업으로 끝남 (단, 이 전환 도중 장착/해제 회귀 버그가 한 번 발생해서, 구조 전환 시 영향 범위를 미리 점검하는 습관이 생김) |
| 협업 | 기획자가 슬롯 구성을 DT에서 직접 조정 가능 |
해결하려는 문제: 증강은 등급(Common/Rare/Epic/Legendary)이 있는데, Legendary만 "보유 시 시너지가 발동하는" 특별한 조건이 필요해 보였다. 이걸 등급별로 다르게 처리할지, 전체를 하나의 방식으로 통일할지 정해야 했다.
고려한 방법
방법 1 — Legendary만 특별 취급: C/R/E는 기존처럼 수치 적용만 하고, Legendary만 별도로 "보유 여부" 추적 로직을 추가.
방법 2 — 모든 등급을 공통 태그로 관리: UNSAugmentDefinition에 GrantedTag 필드를 두고, 모든 등급이 이 방식을 공유. GE의 GrantedTags에 설정해 적용/제거 시 ASC 태그맵에 자동 반영.
RequiredTags로 에디터에서만 연결 가능.최종 선택: 방법 2
선택 이유: C/R/E 등급도 나중에 "이 증강을 갖고 있으면" 조건이 생길 수 있다고 판단했고, Legendary만 따로 처리하면 그 시점에 구조를 다시 나눠야 하는 게 뻔했다. 처음부터 통일해두면 증강을 추가할 때마다 코드 변경 없이 태그만 설정하면 된다.
구현 구조
[방법 1 - Legendary만 특별 취급]
UNSAugmentDefinition (C/R/E) → 수치만 관리
UNSAugmentDefinition (Legendary) → GrantedTag + 시너지 조건 ← 등급별로 다른 구조
[방법 2(최종) - 전체 공통 태그]
UNSAugmentDefinition (모든 등급) → GrantedTag 필드
│
▼
GE.GrantedTags = 이 태그 → 적용/제거 시 ASC 태그맵 자동 반영 ("보유 여부" 추적)
GE.RequiredTags = 다른 증강 태그 → "이 증강 보유 시" 시너지, 코드 수정 없이 에디터에서 연결
│
FNSAugmentInstance → 스택 수("몇 개 보유")는 별도로 관리 (태그와 역할 분리)
결과
| Before (방법 1 가정) | After (방법 2 적용) | |
|---|---|---|
| C/R/E 시너지 조건 추가 시 | 구조를 새로 나눠야 함 | 이미 있는 태그 방식 그대로 사용 |
| 증강 보유 추적 | 등급별로 다른 코드 경로 | 태그 하나로 통일 |
| 시너지 조건 연결 | 코드 수정 필요 | GE RequiredTags 에디터 설정만 |
얻은 효과
| 항목 | 내용 |
|---|---|
| 성능 | GAS의 태그맵 조회는 이미 최적화된 경로라 추가 비용이 거의 없음 |
| 유지보수 | "보유 여부"(태그)와 "스택 수"(인스턴스 데이터)의 책임이 분리돼 헷갈릴 여지가 적음 |
| 확장성 | 신규 증강 추가 시 태그만 붙이면 기존 시너지 시스템에 바로 편입됨 |
| 협업 | 시너지 조건을 코드 담당자 없이 GE 에디터 설정만으로 기획자가 조정 가능 |
해결하려는 문제: 증강 선택창이 열려 있을 때 캐릭터 입력(이동/공격)을 어떻게 처리할지 정해야 했다.
고려한 방법
방법 1 — IMC 스위칭 (초기 설계): 증강창이 열리면 전용 IMC_Augment로 통째로 갈아끼워 게임 입력을 차단하는 모달 방식.
방법 2 — 비차단 게이팅 + 대기열 (최종): 증강 관련 키(1/2/3/T)를 기존 Gameplay IMC에 그대로 두고, C++에서 IsAugmentationPanelOpen() 여부로만 게이팅. 여러 증강 제안이 겹칠 수 있는 상황을 대비해 대기열(PoolQueue, PendingCount)도 함께 도입.
EnterAugmentInputMode/ExitAugmentInputMode 등)를 전부 삭제하고 다시 만들어야 함.최종 선택: 방법 2
선택 이유: 게임 설계 방향이 "증강창은 비차단 오버레이"로 확정됐고, 증강 관련 키(1/2/3/T)가 스킬 키와 안 겹쳐서 애초에 입력 모드를 통째로 바꿀 이유가 사라졌다.
구현 구조
[Before] 증강창 오픈 → IMC_Augment로 전체 스위칭 → 이동/공격 등 게임 입력 차단(모달)
패널 닫힘 → 원래 IMC로 복귀
[After] 증강 입력(1/2/3/T)이 항상 Gameplay IMC 안에 존재
│
▼
Input_AugmentAction 실행 → UIManager->IsAugmentationPanelOpen() 확인만으로 게이팅
│
▼
이동/공격/스킬은 항상 그대로 동작 (비차단 오버레이)
│
여러 증강 제안 발생 시 → PoolQueue(대기열)에 적재 → 순서대로 PendingCount만큼 순차 제시
결과
| Before (IMC 스위칭) | After (비차단 게이팅) | |
|---|---|---|
| 증강창 열림 중 이동/공격 | 차단됨(모달) | 정상 동작 |
| 다른 UI와 동시 오픈 | IMC 우선순위 충돌 가능 | IMC 하나로 유지, 충돌 없음 |
| 여러 증강 제안 동시 발생 | 처리 순서 불명확 | 대기열로 순차 처리 |
얻은 효과
| 항목 | 내용 |
|---|---|
| 성능 | IMC를 매번 갈아끼우는 비용이 없어짐 |
| 유지보수 | 입력 게이팅 로직이 "패널 열림 여부 체크" 한 줄로 단순화 |
| 확장성 | 파츠 UI 등 이후 다른 오버레이 UI도 같은 게이팅 패턴을 참고해 설계됨 |
| 협업 | 기획 쪽 요구사항("비차단으로 바꿔달라")이 코드 구조 변경 없이 게이팅 조건 하나로 수용됨 |
해결하려는 문제: 파츠 보유 정보를 세이브 파일에 어떻게 저장할지 정해야 했다.
고려한 방법
방법 1 — 구조체 통째로 저장 (초기 구현): 파츠의 모든 필드(정의, 등급, 수치 등)를 구조체 그대로 세이브 파일에 직렬화.
방법 2 — ID만 저장 (전환 후): 파츠의 정의 ID만 저장하고, 로드 시 DT에서 최신 정의를 다시 조회해 복원.
최종 선택: 방법 2
선택 이유: 파츠 자체가 DA/DT로 정의돼 있으니, 저장 시엔 ID만 들고 있다가 로드 시 DT를 조회해 복원하는 쪽이 데이터량과 마이그레이션 안정성 면에서 유리하다는 걸 확인했기 때문이다.
구현 구조
[Before] SaveGame.OwnedParts: TArray<FNSPartFullStruct> ← 파츠 전체 스탯/정의를 통째로 저장
│
▼
파츠 DT 수치가 바뀌면 저장된 값과 최신 정의가 어긋남 (마이그레이션 문제)
[After] SaveGame.OwnedParts: TArray<FNSPartId> ← ID만 저장
│
▼
로드 시 DataSubsystem::GetPartRow(Id)로 최신 정의를 다시 조회해 복원
결과
| Before (구조체 저장) | After (ID 저장) | |
|---|---|---|
| 파츠 정의 수치 변경 시 | 기존 세이브와 값이 어긋남 | 항상 최신 정의로 복원, 문제 없음 |
| 세이브 파일 크기 | 필드 수만큼 큼 | ID만 저장, 작음 |
| 로드 로직 | 저장값 그대로 사용 | DT 조회 1회 후 복원 |
얻은 효과
| 항목 | 내용 |
|---|---|
| 성능 | 저장 파일 크기가 작아 저장/로드 자체가 가벼움 |
| 유지보수 | 파츠 밸런스를 수정해도 세이브 마이그레이션 스크립트가 필요 없음 |
| 확장성 | 같은 "ID만 저장, 로드 시 DT 복원" 원칙이 이후 다른 시스템(가이드 진행 상태 등)의 저장 필드에도 참고 기준이 됨 |
| 협업 | 밸런스 담당자가 DT 값을 자유롭게 조정해도 기존 플레이어 세이브가 깨지지 않음 |
해결하려는 문제: 유저 테스트에서 "조작법을 모르겠다", "인런에서 뭘 해야 하는지 모르겠다"는 피드백을 받았다. 화면에 안내를 띄워야 하는데, 안내할 내용이 하나가 아니라 여러 개(이동/점프/대시 3가지, 이후 캐릭터 콘솔·게임시작 콘솔·NPC 방문까지)이고, 이 개수는 앞으로도 계속 늘어날 걸로 예상됐다.
고려한 방법
방법 1 — 단일 텍스트 위젯 (실제로 07/14에 먼저 구현했던 방식): HUD에 텍스트 위젯 하나를 두고, 지금 안내해야 할 문구를 DataTable에서 읽어와 그 위젯에 갈아 끼움.
방법 2 — 배열 기반 체크리스트 위젯 (최종 채택): 안내 항목을 배열 데이터로 구성하고, 항목 하나짜리 엔트리 위젯 + 그걸 여러 개 담는 컨테이너 위젯으로 표시. 항목마다 개별 완료 처리가 가능.
최종 선택: 방법 2
선택 이유: 유저 피드백이 "조작법"에서 끝나지 않고 "인런에서 뭘 해야 하는지"까지 이어졌기 때문에, 안내 항목이 하나로 끝나지 않고 계속 늘어날 게 뻔했다. 실제로도 이동/점프/대시 3개 → 캐릭터 콘솔 → 게임시작 콘솔 → NPC 방문까지 늘어났다. 방법 1로 계속 밀고 갔다면 항목이 늘 때마다 위젯을 다시 만들어야 했을 것이고, 방법 2는 늘어나는 부분을 데이터로만 흡수하기 때문에 장기적으로 비용이 훨씬 적다고 판단했다.
구현 구조
[씬 진입] PlayerController → GuideSubsystem.StartGuide()
│
▼
세이브/DataTable 준비 확인 (아직 안 됐으면 준비 완료 콜백을 기다렸다가 재시도)
│
▼
GetActiveStageRowName() ← 아웃런/인런 파생 클래스가 "지금 몇 번째 단계인지"만 판단
│
├─ 진행할 단계 없음 → HUDWidget.HideGuideChecklist()
│
▼
BuildStageEntries(RowName, OutEntries) ← 파생 클래스가 이 단계의 안내 항목을 배열로 구성
│
▼
HUDWidget.ShowGuideChecklist(Entries) 컨테이너 위젯이 항목 수만큼 엔트리 위젯을 생성
│
[플레이어가 이동/점프/대시 입력, 콘솔 사용, NPC 방문 등 실제 행동]
│
▼
NotifyXxxInput() → 세이브 플래그 갱신 → CompleteItem(ItemId) 해당 줄만 완료 애니메이션
│
▼
(컨테이너의 모든 줄이 완료되어 비워짐) → NotifyChecklistEmptied()
│
▼
GetActiveStageRowName() 재호출 → 다음 단계 있으면 위 과정 반복, 없으면 숨김
공용 골격(UNSGuideSubsystemBase)이 "준비 확인 → 단계 판단 → 항목 표시 → 완료 감지 → 재평가"라는 흐름 전체를 갖고 있고, 아웃런/인런 파생 클래스는 GetActiveStageRowName()과 BuildStageEntries() 두 함수만 채운다.
결과
| Before (방법 1, 07/14) | After (방법 2, 07/21) | |
|---|---|---|
| 안내 항목 표현 | 텍스트 한 줄, 순차 교체 | 배열 항목, 동시에 여러 줄 + 개별 완료 표시 |
| 새 안내 단계 추가 | 위젯 로직을 다시 손봐야 함 | DT/배열에 항목만 추가 |
| 대상 씬 | 아웃런 전용 서브시스템 | 공용 베이스 + 아웃런/인런 파생으로 확장 |
얻은 효과
| 항목 | 내용 |
|---|---|
| 성능 | CurrentChecklistStage로 "지금 표시 중인 단계와 같으면 재스폰하지 않음" 가드를 둬서, 매 입력마다 위젯을 다시 만드는 대신 필요한 시점에만 컨테이너를 새로 그림 |
| 유지보수 | 신규 안내 단계를 추가할 때 건드릴 곳이 파생 클래스의 두 함수(GetActiveStageRowName, BuildStageEntries)로 고정돼 있어, 공용 골격 코드는 안 건드려도 됨 |
| 확장성 | 아웃런 전용으로 시작한 구조가 인런 튜토리얼까지 커버하도록 그대로 확장됐고, 이후 웨이포인트 마커 시스템도 이 서브시스템의 판단 결과를 그대로 받아 쓰는 식으로 얹혔음 |
| 협업 | 안내 문구는 GuideText DataTable로 분리돼 있어 기획자가 문구만 수정 가능하고, 코드는 "몇 번째 단계인지"와 "그 단계에 뭘 보여줄지"만 담당 |