26.08.10
팀프로젝트 정리 김민준
UE5 4인 협동 호러 게임 개발 TIL (Player 파트)
프로젝트: Project_Dwarf_Extraction — Lethal Company 스타일 4인 협동 1인칭 호러 엔진: Unreal Engine 5.4.4 담당: Player 파트 (이동·전투·생존·상호작용 전반) 협업: GitHub + Git LFS, 브랜치
feature/Player/develop, 팀원별 파트 분담(아이템·몬스터·UI·세션)
프로젝트를 처음부터 끝까지 진행하며 배운 것들을 주제별로 정리했다. 단순 기능 나열이 아니라 "왜 그렇게 해야 하는가"와 "어디서 막혔고 어떻게 뚫었는가"를 남긴다.
1. GAS (Gameplay Ability System)
프로젝트의 이동·스탯·스킬을 모두 GAS로 구성했다. GAS는 초반 러닝커브가 크지만, 한 번 구조를 잡으면 확장이 쉽고 네트워크 처리를 상당 부분 자동화해준다는 게 큰 장점이었다.
1-1. 어디에 무엇을 두는가 (구조 설계)
- ASC와 AttributeSet은 PlayerState에 배치한다.
- Character에 두면 리스폰 시 초기화된다. PlayerState는 리스폰을 거쳐도 유지되므로 체력·스태미나가 보존된다.
- Character는
IAbilitySystemInterface를 구현하고,PossessedBy()(서버)와OnRep_PlayerState()(클라이언트) 양쪽에서InitAbilityActorInfo()를 호출한다.- 서버와 클라이언트가 ASC를 인식하는 시점이 다르기 때문에 두 곳 모두 필요하다. 한쪽만 하면 한쪽에서 능력이 안 먹는다.
1-2. Attribute 값 변화 감지의 함정
PostGameplayEffectExecute()는 Instant 이펙트에서만 호출된다.- Duration/Infinite 이펙트로 값이 바뀌면 호출되지 않는다.
- 값 변화를 항상 감지하려면
GetGameplayAttributeValueChangeDelegate(Attribute).AddUObject(...)를 쓴다. - 예: MoveSpeed 어트리뷰트 →
CharacterMovement->MaxWalkSpeed동기화는 이 델리게이트로 처리했다.
- 클램핑(0~Max 제한)은
PostGameplayEffectExecute에서 한다.
1-3. GameplayEffect 종류와 용도
| Duration Policy | 용도 | 실제 사용 예 |
|---|---|---|
| Instant | 즉시 1회 | GE_Damage, GE_JumpCost, GE_Revive |
| Duration | 일정 시간 지속 | 버프/디버프 |
| Infinite | 수동 제거까지 | GE_SprintCost, GE_StaminaRegen |
- Period를 설정하면 주기 적용 (예: Period 0.2 → 0.2초마다 스태미나 감소).
- SetByCaller: 코드에서 값을 넘겨받는 방식. 데미지량·회복량처럼 가변값에 쓴다.
cpp
FGameplayEffectContextHandle Ctx = ASC->MakeEffectContext(); FGameplayEffectSpecHandle Spec = ASC->MakeOutgoingSpec(EffectClass, 1.f, Ctx); Spec.Data->SetSetByCallerMagnitude(Tag, Value); ASC->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get());- GE 에디터에서 Modifier Magnitude →
Set by Caller→ Data Tag 지정이 필요하다. - 데미지는 음수, 회복은 양수를 넘기면 같은 Add 구조를 반대로 재활용할 수 있다 (GE_Damage와 GE_Revive가 동일 메커니즘).
- GE 에디터에서 Modifier Magnitude →
1-4. GameplayTag로 상태 제어
- Activation Blocked Tags: 이 태그를 가진 대상은 해당 어빌리티를 발동할 수 없다.
State.Downed→ Sprint/Interact 차단State.Crouching→ Sprint 차단 (웅크린 채 스프린트 시 "비용만 내고 효과 없는" 버그 해결)
- Ability Tags: 어빌리티 식별용.
CancelAbilities(&TagContainer)로 특정 어빌리티만 골라 취소. AddLooseGameplayTag()/RemoveLooseGameplayTag(): 코드에서 임시 태그 부여.- 주의: Loose 태그는 자동 복제되지 않는다(서버에만 붙음).
1-5. 어빌리티 네트워크 (가장 많이 헤맨 부분)
- NetExecutionPolicy가 "어디서 실행되는가"를 결정한다.
LocalPredicted: 클라이언트 예측 실행ServerOnly: 클라이언트가 입력해도 서버에서 실행 ← 서버 권위 로직에 적합
- 함정:
ServerOnly어빌리티는 활성화는 자동으로 서버에 전달되지만, 취소(CancelAbilities)는 전달되지 않는다.- 클라이언트에서 취소하려면 별도 Server RPC가 필요하다.
cppvoid APlayerCharacter::InteractReleased(const FInputActionValue& Value) { if (HasAuthority()) Server_CancelInteract_Implementation(); else Server_CancelInteract(); // 클라 → 서버 전달 }- 홀드형 부활에서 이걸 몰라서 "클라이언트가 부활 도중 손을 떼도 계속 부활되는" 버그를 겪었다.
2. 네트워크 / 멀티플레이
리슨 서버 + 4인 구조. 멀티플레이 버그의 대부분은 아래 원칙을 안 지켜서 생겼다.
2-1. 3분할 원칙 (멀티 버그 예방의 핵심)
역할을 명확히 나누면 대부분의 멀티 버그가 예방된다.
| 구분 | 담당 | 실제 예시 |
|---|---|---|
HasAuthority() | 상태 결정 (서버 권위) | 다운/사망 판정, 타이머, 데미지 적용 |
OnRep_ | 클라이언트 화면 연출 | 래그돌, 카메라 부착, 화면 효과 |
IsLocallyControlled() | 내 화면에만 보이는 것 | 1인칭 카메라, 포스트프로세스, 머리 숨김 |
적용 예 — 사망 처리:
서버: BleedOut → HandleDeath (상태 결정 + 타이머)
↓ ApplyDeathEffects (서버 자기 화면)
bIsDead 복제 ↓
클라: OnRep_IsDead → ApplyDeathEffects (클라 화면 연출)
- 상태 변경 함수와 연출 함수를 분리하지 않으면 "서버에선 래그돌인데 클라에선 안 쓰러지는" 현상이 난다.
2-2. RPC 종류
| 종류 | 방향 | 용도 |
|---|---|---|
UFUNCTION(Server, Reliable) | 클라 → 서버 | 입력 전달, 취소 요청 |
UFUNCTION(NetMulticast, Unreliable) | 서버 → 전체 | 몽타주/이펙트 등 시각 효과 |
UFUNCTION(Client, Reliable) | 서버 → 특정 클라 | 개인 알림 |
- 시각 효과는
Unreliable이 적절(한 프레임 놓쳐도 무방, 네트워크 부담 적음). - RPC 구현 규칙:
.h에Multicast_X()선언 →.cpp에Multicast_X_Implementation()구현 → 호출은Multicast_X()(접미사 없이)._Implementation없이 구현하면 "unresolved external" 링커 에러.Multicast_X(접미사 없는 본체)를 직접 정의하면 "already defined" 에러 (엔진이 자동 생성함).
2-3. 복제 변수
// .h
UPROPERTY(ReplicatedUsing = OnRep_IsDowned)
bool bIsDowned = false;
// .cpp — 반드시 등록해야 복제됨
void APlayerCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& Out) const
{
Super::GetLifetimeReplicatedProps(Out);
DOREPLIFETIME(APlayerCharacter, bIsDowned);
}
.h에Replicated를 붙였는데GetLifetimeReplicatedProps에 등록을 안 하면 복제가 안 된다. 둘 다 필요.- 머지 도중 이 함수가 한쪽 버전으로 덮어써져 팀원의 복제 변수가 누락 → 클라이언트에서 메시가 캡슐만 보이는 버그를 겪었다.
2-4. 몽타주 복제
- 애니메이션 몽타주는 자동 복제되지 않는다.
- 다른 플레이어 화면에도 보이려면
NetMulticastRPC로 서버가 전파해야 한다. - 서버에서 호출해야 전체로 퍼진다.
ServerOnly어빌리티 안에서 Multicast를 호출하면 자연스럽게 서버 기점이 된다.
2-5. 서버에서 카메라 회전을 못 읽는 문제
- 카메라 컴포넌트 회전은 원격 클라이언트에 대해 서버에서 갱신되지 않는다.
- 서버 트레이스(상호작용·발사)에는
Controller->GetPlayerViewPoint()를 써야 정확하다. 이 값은 복제된다. - 같은 이유로, 애니메이션 조준 각도도 카메라가 아니라
Get Base Aim Rotation을 써야 다른 클라이언트에서 보인다.
2-6. 콜리전 채널
- 캐릭터 캡슐의 기본 프리셋(
Pawn)은 Visibility 채널을 Ignore하는 경우가 많다. - 플레이어를 트레이스로 잡으려면
ECC_Pawn채널을 쓴다.- 부활에서 다운된 플레이어에게 E가 안 먹힌 원인이 이것이었다(Visibility로는 캡슐을 못 맞힘).
- 아이템(WorldDynamic 등)은 Visibility로 잡히므로, 둘 다 필요하면 순차 트레이스.
3. 애니메이션
가장 시간을 많이 쓴 영역. State Machine으로 지속 상태를, Montage로 단발 액션을, Layered Blend로 상하체 분리를 처리하는 조합이 핵심이었다.
3-1. 리타게팅 파이프라인
다른 스켈레톤(Manny)의 애니메이션을 프로젝트 캐릭터(Astronaut)로 가져오는 표준 절차:
- IK Rig 생성 (소스/타겟 각각) →
Auto Create Retarget Chains→ Retarget Root =pelvis - IK Retargeter 생성 → Source/Target IK Rig 지정
- Root Settings 조정
Scale Horizontal = 0→ 루트 수평 이동 제거 = In-Place 변환Rotation Alpha조정 → 좌우 이동 애니메이션이 90도 틀어지는 문제 해결
- Asset Browser에서 애니메이션 선택 →
Export Selected Animations(Suffix_Astro등)
- 리타게팅 결과물은 원본 애니메이션·스켈레톤·IK 에셋을 참조한다. 원본을 지우면 깨진다.
- 지우기 전 Reference Viewer로 의존성 확인. 개발 중엔 그냥 남겨두는 게 안전.
3-2. Root Motion vs In-Place
| 방식 | 이동 주체 | 용도 |
|---|---|---|
| Root Motion | 애니메이션에 구워진 루트 이동 | 컷신, 정밀 공격 이동 |
| In-Place | CharacterMovement | 일반 이동 (본 프로젝트) |
- Root Motion 애니메이션을 In-Place처럼 재생하면 캐릭터 위치가 튄다.
- 리타게터 Root Settings에서 이동을 제거하거나, ABP
Root Motion Mode를 조정한다.
3-3. Additive 애니메이션 함정
- Additive는 "기본 포즈와의 차이값"만 담은 애니메이션이다.
- State Machine에서 일반 재생하면 메시가 쪼그라들며 무너진다.
- 미리보기로는 구분이 안 된다 (에디터가 Additive를 감안해 정상으로 보여줌).
- 반드시 Asset Details의
Additive Settings > Additive Anim Type을 직접 확인한다. 일반 재생용은No additive로.
3-4. Blend Space (방향성 이동)
- 2D Blend Space로 8방향 이동 구현: Horizontal=
Direction(-180180), Vertical=Speed(0최대). - 대각선 샘플이 없어도 인접 샘플을 자동 블렌딩한다.
- ±180 양쪽 끝에 같은 애니메이션을 배치해야 후진 방향이 자연스럽다.
Calculate Direction노드: 속도 벡터가 몸 정면 기준 몇 도인지 반환. 입력은Get Velocity+Get Actor Rotation.
3-5. 각도 값의 함정 — Normalize Axis
Get Base Aim Rotation의 Pitch가 0~360 범위로 나올 수 있다 (아래를 보면 -30이 아니라 330).- 이 값에 배율을 곱하면 각도가 급격히 튄다 (330 × -0.5 = -165).
- 해결:
Normalize Axis노드로 -180~180 정규화 후 사용. - 교훈: 각도가 이상하게 튀면
Normalize Axis부터 의심한다.
3-6. State Machine 전환 설계
실전에서 필요했던 전환들:
| 전환 | 조건 | 이유 |
|---|---|---|
| Ground → Jump | bIsFalling && VerticalVelocity > 100 | 점프(위로) |
| Ground → Fall | bIsFalling && VerticalVelocity <= 100 | 낙하 |
| Land → Jump | bIsFalling && VerticalVelocity > 100 | 연속 점프 대응 |
| Land → Ground | Speed > 10 || TimeRemaining < 0.1 | 착지 즉시 이동 반응성 |
- 연속 점프 문제: Land 모션 재생 중 다시 점프하면 애니메이션이 물리를 못 따라간다. Land에서 Jump로 나가는 직행 경로를 만들어 해결.
- State Machine 출력 핀은 하나만 연결 가능하다. 여러 곳에 쓰려면 → 아래 Cached Pose.
3-7. Cached Pose (자주 쓰는 핵심 패턴)
- State Machine 출력을 여러 곳에서 쓰려면 캐시를 사용한다.
- State Machine 출력 →
New Save cached pose(이름 지정) - 필요한 곳마다
Use cached pose '이름'노드를 만들어 연결 (여러 개 가능)
- State Machine 출력 →
- 성능상으로도 유리(한 번만 계산).
- 채굴 몽타주에서 하체를 Base Pose와 Slot Source 두 곳에 넣어야 했을 때 이걸로 해결했다.
3-8. Layered Blend Per Bone (상하체 분리)
1인칭에서 팔 흔들림이 시야를 방해할 때, 아이템 자세를 상체만 덮을 때 사용.
하체(이동 Blend Space) → Base Pose
Layered blend per bone → 출력
상체(아이템 Idle 포즈) → Blend Poses 0
Branch Filters에Bone Name(예:spine_01)과Blend Depth지정.- Blend Depth가 낮으면 지정 본에서 멀어질수록 원본이 섞여 팔이 흔들린다 → Depth를 올려 완전히 고정.
- 특정 본만 확실히 고정하려면
clavicle_r/clavicle_l을 Branch Filter에 직접 추가. - 좌우 이동 시 팔이 사선으로 나가던 문제를 Blend Depth를 3 이상으로 올려 해결.
- 확장성: 아이템이 늘어도 상체 포즈만 추가하면 된다. 실제로 곡괭이 추가 시 ABP를 거의 안 건드렸다.
3-9. Blend Poses by Enum (데이터 기반 분기)
- Enum 값에 따라 포즈를 분기 (예: 들고 있는 아이템 카테고리).
- 핀은 자동 생성되지 않는다 → 노드 우클릭 →
Add Blend Pin→ 사용할 값 선택. Default Pose를 활용하면 나머지 값을 전부 커버하므로 모든 값에 핀을 만들 필요 없다.- 중첩 가능: 바깥은
EItemCategory(Weapon/Tool/Light), Weapon 안쪽은EWeaponAnimType(Pistol/Rifle)로 2단 분기해서 무기 종류를 구분했다.
3-10. Montage (단발 액션)
| State Machine | Montage | |
|---|---|---|
| 성격 | 조건에 따라 지속/반복 | 특정 순간 1회 재생 |
| 예시 | 걷기, 웅크림, 다운 | 공격, 발사, 채굴, 피격 |
- 애니메이션 우클릭 →
Create AnimMontage. - Slot: 몽타주가 재생될 통로. AnimGraph에
Slot '이름'노드를 배치해야 재생된다. - 상체만 재생하려면 전용 Slot(
UpperBody)을 만들고Layered blend per bone으로 상체에만 적용. - 전신 몽타주라도 UpperBody 슬롯 + 상체 블렌드로 처리하면 하체는 이동을 유지한다(채굴·피격이 이 방식).
3-11. Transform (Modify) Bone (에셋 없이 본 회전)
에셋 없이 상체 조준, 팔 각도 보정에 사용한 실용 도구.
Rotation Mode:Add to Existing,Rotation Space:Bone Space.- 축(X/Y/Z)은 본 로컬 축에 따라 다르므로 넣어보며 찾는다.
- 아이템 자세의 팔 각도를 화면에서 덜 가리게 내릴 때, 상체 조준(Aim Pitch)에 사용.
3-12. Additive Layer Track (애니메이션 비파괴 수정)
- 애니메이션 에디터에서 본 선택 → 기즈모로 조정 →
+ Key. - 원본을 안 건드리는 비파괴 수정(트랙만 지우면 원복).
- 다운 자세를 더 눕히거나 특정 애니메이션의 팔 각도를 보정할 때 사용.
4. 1인칭 카메라
4-1. 표준 설정
bUseControllerRotationYaw = true; // 몸통이 카메라 좌우를 즉시 따라감
GetCharacterMovement()->bOrientRotationToMovement = false;
FirstPersonCamera->bUsePawnControlRotation = true; // Pitch는 독립
- 1인칭은 몸통이 항상 카메라 Yaw를 따라가므로, 고개가 꺾이는 현상 자체가 생기지 않는다.
- 시야각 제한:
PC->PlayerCameraManager->ViewPitchMin/Max.
4-2. 자기 몸이 시야를 가리는 문제
| 상황 | 해결 |
|---|---|
| 머리/헬멧이 가림 | HideBoneByName("head") — IsLocallyControlled()일 때만 |
| 사망 시 목 없는 시체 | UnHideBoneByName()으로 복원 |
| 웅크림 시 상체가 가림 | OnStartCrouch/OnEndCrouch에서 카메라 오프셋 앞으로 |
| 다운 시 화면 어두움 | 캡슐 부착 유지 + 오프셋 조정 (본 부착은 축 뒤틀림으로 실패) |
4-3. 카메라 부착 대상 선택 기준
| 상황 | 부착 대상 | 이유 |
|---|---|---|
| 사망(래그돌) | 머리 본 | 물리 시뮬레이션이라 위치가 매번 다름 |
| 다운(애니메이션) | 캡슐 + 오프셋 | 고정 자세라 좌표로 충분, 본 축 뒤틀림 회피 |
- 본에 부착하면 오프셋/회전이 본 로컬 축 기준이라 예측이 어렵다.
4-4. BP 값 캐싱 시점 (자주 밟는 함정)
// ✗ 생성자 — BP가 덮어쓰기 전의 C++ 기본값만 얻음 (0,0,64)
DefaultCameraLocation = FirstPersonCamera->GetRelativeLocation();
// ✓ BeginPlay — BP에서 설정한 실제 값 (20,0,56)
void APlayerCharacter::BeginPlay()
{
DefaultCameraLocation = FirstPersonCamera->GetRelativeLocation();
}
- BP에서 수정 가능한 값을 C++에서 기억하려면 반드시
BeginPlay에서 캐싱한다. 생성자는 BP 오버라이드 이전 시점이다. - 부활 후 카메라 위치가 잘못 복원되던 버그의 원인이었다.
5. 시스템 설계 사례
5-1. 다운 / 부활 시스템
체력 0 → 다운(EnterDownedState)
├→ 팀원이 E 홀드 → 부활(Revive) → 체력 일부 회복
└→ 출혈 타이머 만료 → 사망(HandleDeath) → 관전
bIsDowned/bIsDead둘 다 복제 +OnRep_으로 연출. 다운/사망을 대칭 구조로 만들면 읽기 쉽다.- 플레이어 자신이
IInteractableInterface를 구현 → 다운된 플레이어를 E로 조준하면 기존 상호작용 시스템이 그대로 부활 처리. - 홀드형 상호작용:
- 입력을
Started+Completed양쪽에 바인딩해야 "떼는 시점"을 안다. - 홀드 진행은 타이머(0.1초 간격) + 유효성 검사 + 경과 누적.
- 트레이스는 진입 시 1회만 (매 틱 트레이스하면 마우스가 조금만 흔들려도 취소됨).
- 모든 종료 경로가
EndAbility를 거치므로 정리 코드는 거기 한 곳에.
- 입력을
- 부활 중 방해 처리: 시전자 피격 시
OnHealthChanged에서 취소, 출혈 타이머는PauseTimer/UnPauseTimer(남은 시간 유지).
5-2. 관전 시스템 (대상 고정 3인칭)
SpectatorPawn+ SpringArm(궤도 회전) + Camera 구조.SpectateTarget을 복제 변수로 두면 서버/클라가 같은 대상을 따라가 위치 보정 충돌이 없다.- 대상 전환 입력은
Triggered가 아니라Started(Triggered는 누르는 동안 매 프레임 발동 → 대상이 왔다갔다 함). TActorIterator순서는 보장되지 않으므로 Sort로 순서 고정 (안 하면 클릭할 때마다 엉뚱한 대상).- SpringArm
ProbeChannel = ECC_Camera: 벽은 막고 플레이어 몸은 통과(대부분 벽은 Camera Block, Pawn은 Ignore). - 게임플레이 입력과 겹치지 않게 관전 전용 IMC로 교체(
Clear All Mappings필수).
5-3. 개별 상태를 가진 액터 (문/스위치)
- 초기 구현은 문 상태가 GameState에 단 하나 → 스위치 하나가 모든 문을 여는 버그.
- 해결: 상태를 각 문 액터가 소유(
bIsOpen복제) + 스위치가EditInstanceOnly로 대상 문을 개별 참조. EditInstanceOnly가 핵심: BP 기본값이 아니라 레벨에 배치된 인스턴스마다 다른 값 지정 가능.- 부드러운 개폐:
bIsOpen만 복제하고 각 머신이FInterpConstantTo+InterpEaseInOut로 자체 애니메이션(위치를 매 프레임 복제하지 않음). 도착하면SetActorTickEnabled(false)로 Tick 중지.
5-4. 트랩 (즉시 오버랩 함정)
- 콜리전이 꺼져 있던 컴포넌트를 켜는 순간, 이미 그 안에 있던 액터에 BeginOverlap이 즉시 발생한다.
- 덫 발동 시 켠 해제 발판(
OffCol)에 갇힌 당사자가 겹쳐 있으면 → 즉시 해제되어 "갇혔는데 덫이 안 보이는" 버그.
- 덫 발동 시 켠 해제 발판(
- 해결 3종: ① 갇힌 사람은 해제 대상에서 제외(
Contains가드) ② 배열 추가를 상태 변경보다 먼저 ③ 위치를 물리적으로 분리(가장 확실). - Multicast는 동기 실행되므로, 켜졌다 꺼지는 전 과정이 다음 노드 실행 전에 끝나버릴 수 있다.
6. 아이템 / 무기
6-1. 소켓 부착
- 스켈레톤 에디터 → 본 우클릭 →
Add Socket→Add Preview Asset로 실시간 위치 확인. - 본에 직접 붙이면 관절 원점에 얹혀 어색하다 → 소켓으로 오프셋을 잡는 게 정석.
- 아이템별로 소켓을 따로 만들면 각각 다른 각도로 쥘 수 있다.
6-2. 부착 코드의 함정
// ✗ 캡슐(루트)에 붙이면 소켓이 없어 원점(몸통 중앙)에 박힘
Item->AttachToComponent(Pawn->GetRootComponent(), Rules);
// ✓ 스켈레탈 메시 + 소켓 이름
Item->AttachToComponent(Char->GetMesh(), Rules, Def->AttachSocket);
- 언리얼은 존재하지 않는 소켓 이름을 주면 조용히 무시하고 원점에 붙인다 (에러 없음).
SnapToTargetNotIncludingScale은 소켓 Scale을 무시 → 크기 조정은 아이템 BP의 MeshComp에서.
6-3. 데이터 기반 설계
UItemDefinition(DataAsset)에 Category / AttachSocket / Mesh / Weight를 넣고 BP에서 관리.- 로직은 코드에, 데이터는 DA에 → 아이템이 늘어도 코드 수정 없이 DA 추가만으로 확장.
- 무기 자세도
EWeaponAnimTypeenum을WeaponItem에 두고 ABP가 읽어 분기 → 무기 종류가 늘어도 데이터로 관리.
6-4. Static Mesh vs Skeletal Mesh
- 아이템 시스템(
ItemBase)이 Static MeshComponent 기준으로 콜리전·부착·표시를 관리한다. - 총이 Skeletal Mesh로 제작된 경우:
Make Static Mesh로 변환하는 게 시스템과 일관된다.- 단,
Make Static Mesh는 원본에 딸린 여분 오브젝트(바닥 Plane, 총알 등 잔여 본)까지 통째로 합친다 → 블렌더 등으로 원본에서 총만 추출해 재임포트하는 게 근본 해결. - Skeletal Mesh Component로 우회하면
ItemBase의 콜리전 관리 밖이라 예외 처리가 계속 쌓인다(줍기 시 물리 솟구침, 표시/부착 문제). - 교훈: 시스템이 기대하는 형식에 맞추는 게 결국 더 빠르다.
6-5. 커스터마이징 (색상 선택)
- 색 세트를
PrimaryDataAsset으로 정의(부위별 머티리얼 묶음) → 색 추가가 DA 수정만으로. ColorIndex를 복제 +OnRep으로 각 클라이언트가 머티리얼 교체.- 로비(다른 파트)와의 계약: 로비는 인덱스만 저장, 인게임은 그 값을 읽어 적용 → 파트 간 결합을 최소화.
- 머티리얼 슬롯은 메시마다 순서가 다르므로 슬롯 번호(또는 이름)를 실제 메시에서 확인해야 한다.
6-6. 피격 리액션 (방향 계산)
- 공격자 위치와 내
Forward/Right벡터의 Dot Product로 앞/뒤/좌/우 판정:cppconst float ForwardDot = FVector::DotProduct(Forward, ToAttacker); const float RightDot = FVector::DotProduct(Right, ToAttacker); if (FMath::Abs(ForwardDot) >= FMath::Abs(RightDot)) return (ForwardDot >= 0) ? Front : Back; else return (RightDot >= 0) ? Right : Left; - 방향별 몽타주를 배열로 두고 인덱스로 재생, UpperBody 슬롯으로 이동 중에도 자연스럽게.
- 공격자 정보는
ReceivePlayerDamage(Damage, DamageCauser)에서 얻는다(OnHealthChanged엔 공격자 정보가 없음).
7. 빌드 / 컴파일 트러블슈팅
7-1. 에러 유형 구분 (가장 유용했던 지식)
| 코드 | 의미 | 대응 |
|---|---|---|
C#### | 진짜 컴파일러 에러 | 이것만 보면 됨 |
E#### | IntelliSense 가짜 에러 | 무시 (특히 ArchiveProxy.h, FTextureBuildSettings) |
MSB3073 | 빌드 실패 통보 | 원인이 아님. 진짜 에러를 찾아야 함 |
7-2. 자주 만난 패턴
- 빌드가 몇 초 만에 실패 + C 에러 없음 → 에디터가 DLL을 잠금 → 에디터 끄고 재빌드.
- 오류 목록에 진짜 에러가 안 보임 → 출력(Output) 창에서
error검색. #include found after .generated.h→.generated.h는 반드시 마지막 include.C4129+C1004→ 문자열의 따옴표가 스마트 따옴표로 깨짐 → 영어로 다시 타이핑.- 클래스 선언 중복 → 수정 안내를 "교체"가 아닌 "추가"로 적용하면 발생.
UCLASS()+class ... {+GENERATED_BODY()한 벌만. - UENUM/USTRUCT는 클래스 밖,
.generated.hinclude 위에 둔다. - 링커 "unresolved external" → 헤더에 선언했는데
.cpp에 구현이 없음 (특히 머지 후 함수 누락).
8. Git / LFS 트러블슈팅
8-1. 기본 원칙
- 작업 시작 전 현재 브랜치 확인 (develop에서 작업하다 꼬인 전례).
- 커밋 → 푸시 → Pull 순서. 커밋 안 된 변경이 있는 상태로 Pull하면 스태시 충돌이라는 더 복잡한 상황이 된다.
- develop을 내 브랜치로 먼저 머지 → 내 브랜치에서 충돌 해결 → 그다음 develop으로 역방향 머지.
8-2. .uasset + LFS 충돌 (가장 위험했던 사고)
.uasset은 LFS 포인터(텍스트)로 저장되는데, 머지 충돌 시 포인터 안에 마커가 박힌다:
version https://git-lfs.github.com/spec/v1
<<<<<<< HEAD
oid sha256:00502451... size 45990
=======
oid sha256:b1d4b54f... size 46208
>>>>>>> d609341...
- 이 상태로 커밋하면 에디터가 파일을 못 읽는다 (
The summary for the package ... is invalid).
진단
findstr /s /m "<<<<<<< HEAD" Content\*.uasset
type "경로\파일.uasset"
복구
git checkout <커밋해시> -- "경로/파일.uasset"
git lfs pull
- 파일 크기로 검증: 254바이트 = 포인터(비정상), 수십 KB = 실제 에셋.
- 에디터에서 정상 동작을 확인하기 전에는 develop에 push하지 않는다 (깨진 파일이 팀 전체로 퍼짐).
8-3. 머지 후 반드시 확인할 것
- BP 설정 유실: Anim Class, GE 참조, 어빌리티 클래스, IMC 등록이 이전 값으로 되돌아감.
- 복제 등록 누락:
GetLifetimeReplicatedProps가 한쪽 버전으로 덮어써짐. - 코드가 주석 처리됨: diff 도구가 블록을 잘못 처리 (IMC 등록 코드가 통째로 주석 처리되어 입력이 안 먹은 사례).
8-4. 텍스트 파일 충돌 병합 판단
- 두 작업이 서로 다른 기능이면 대부분 둘 다 남기는 것이 정답.
- 조건문은 한쪽을 버리지 말고 AND로 결합 (예:
!bIsDead && !bIsDowned). - 팀원이 씌운
HasAuthority()같은 방어 코드는 유지.
8-5. LFS 용량 초과
- GitHub 무료 LFS는 월 1GB 저장 + 1GB 대역폭. 애셋·사운드가 쌓이면 금방 초과.
This repository exceeded its LFS budget→ 저장소 소유자가 Data pack 구매 또는 용량 증설해야 함(개인이 못 고침).- 초과 시 pull/push 모두 막히므로, 로컬 커밋만 해두고 해결 후 push.
9. 협업 설계 교훈
- 인터페이스로 파트 간 계약을 만든다.
IInteractableInterface→ 아이템/문/플레이어 누가 구현하든 E키 하나로 동작.ReceivePlayerDamage(float, AActor*)→ 몬스터 파트가 호출할 진입점.
- 데이터는 DataAsset, 로직은 코드. 아이템/무기/색상 모두 DA로 관리해 확장 시 코드 수정 최소화.
- 팀원 코드를 수정했으면 반드시 공유. 말 안 하면 다음 머지에서 충돌하거나 되돌아간다.
- 작업 전 최신 코드를 확인. 채굴 몽타주를 직접 만들려다, pull 후 팀원이 이미
SwingMontage+Multicast_PlaySwing을 구현해둔 걸 발견한 적이 있다. - 확장 가능한 구조를 먼저 고민.
Blend Poses by Enum+ Default Pose 덕에 곡괭이 추가 시 ABP를 거의 안 건드렸다.
10. 디버깅 습관
showdebug abilitysystem— 어트리뷰트 값 + 활성 태그 실시간 확인.UFUNCTION(Exec)치트로 테스트 시나리오 재현 (Cheat_Damage,Cheat_SetColor등).- 동시 입력이 불가능한 상황은 지연 실행 치트로 해결 (
Cheat_DamageDelayed 20 3: 3초 뒤 데미지 → 그 사이 홀드).
- 동시 입력이 불가능한 상황은 지연 실행 치트로 해결 (
AddOnScreenDebugMessage의 Key를 고정값으로 주면 같은 줄이 갱신됨(-1이면 계속 쌓임).- 증상만으로 추측하지 말고 로그로 원인을 좁힌 뒤 수정.
- 트레이스가 안 맞을 때 두 채널을 동시에 검사해 채널 문제를 확정한 사례.
- 트랩이 안 보일 때
Print String으로 이벤트 호출 여부부터 확인한 사례.
- 미리보기와 실제 게임 동작은 다를 수 있다 (Additive, Root Motion) → 최종 판정은 Play에서.
- F8 이젝트로 런타임 중 액터의 실제 값(Visible, Transform 등)을 직접 확인.
회고
이 프로젝트에서 가장 크게 배운 건 "기능을 만드는 것"보다 "구조를 잡는 것"이 중요하다는 점이다.
- 멀티플레이는
HasAuthority/OnRep/IsLocallyControlled3분할만 지켜도 버그의 대부분이 예방됐다. - 데이터 기반 설계(DataAsset + Enum 분기)를 초반에 잡아두니, 후반에 아이템·무기를 추가할 때 코드를 거의 안 건드리고 확장할 수 있었다.
- 가장 많이 시간을 잡아먹은 건 기능 구현이 아니라 머지 충돌과 팀원 코드와의 정합성이었다. 협업에서는 "내 코드가 되는가"보다 "합쳤을 때 되는가"가 진짜 문제다.
- 에러 메시지를 읽는 법(C vs E vs MSB), 미리보기의 함정, 로그로 좁히는 습관 — 이런 "디버깅 리터러시"가 실제 개발 속도를 좌우했다.



