
너 쓸데 없는 파일은 없는 알찬(?) 녀석이었구나.(Muzzle Point 파일 레퍼런스 체크)

Cascade to Niagara 플러그인 까는 짤


convert하는 짤 이러면 니아가라 생김 사진은 없지만 기본 디폴트 히트 파티클도 마찬가지 적용

하지만 더 쉬운 방법이 있다! 미르님께 배워온 꿀팁 일단 빈 맵에 필요한 에셋을 배치하고 그대로 이주하면 알잘딱으로 다 넘어갑니다(파티클 배치한 빈맵 짤)
이렇게 해서 이주 했는데 instance도 있긴 하던데 그거는 지우면 될 듯 map이랑
피 이펙트가 안 넘어온 것 같아서 Dream 쪽 이주 로그를 확인했어요. 확인 결과를 일지에 적고 정리해 드릴게요.
수정됨
19.js
+2
-1
네, instance랑 맵은 지워도 돼요. 그런데 이번 이주로는 피랑 파편이 안 넘어왔어요.
무엇이 넘어왔나
Dream 로그를 보면 이주된 파일은 이 9개가 전부예요.
맵 NewFolder/ToReallmoveParticle 1개
HLOD 레이어 4개: Content/NewFolder/에 2개, Content/ 바로 아래에 2개. 말한 instance가 이거예요.
액터 파일 4개 (ExternalActors/NewFolder/ToReallmoveParticle/...)
P_Blood_Splat_Cone_Converted, P_Default_Converted와 그 머티리얼·텍스처는 목록에 없어요. DreamVeil에는 Realistic_Starter_VFX_Pack_Vol2 폴더 자체가 없어요. 넘어온 액터 파일은 파티클을 가리키는데, 정작 파티클 파일이 없는 상태예요.
왜 안 됐나
빈 맵을 World Partition 맵(Open World 계열)으로 만들어서 그래요. HLOD 레이어 파일이 딸려 온 게 그 증거예요.
World Partition 맵은 배치한 액터를 맵 파일 안에 넣지 않고 ExternalActors에 따로 저장해요.
이주는 맵 파일이 참조하는 것만 따라가요. 그래서 액터 파일은 넘어갔지만, 액터 안에 있는 파티클은 빠졌어요.
미르님 꿀팁은 맞는 방법이에요. 다음에 쓸 때는 File → New Level → Empty Level로 만드세요. Empty Open World나 Open World는 안 돼요. 그러면 액터가 맵 파일 안에 저장돼서 딸린 에셋이 다 따라와요.
지금 할 것
파티클 옮기기: 두 개뿐이라 직접 옮기는 게 빨라요. Dream에서 _Converted 두 개를 선택 → Asset Actions → Migrate... → Asset Report에 머티리얼과 텍스처가 체크돼 있는지 확인 → DreamVeil Content를 선택하세요.
DreamVeil 정리: 에디터에서 지우세요.
NewFolder 폴더: 맵과 HLOD 레이어 2개
Content 바로 아래의 ToReallmoveParticle_HLODLayer_Instanced, ToReallmoveParticle_HLODLayer_Merged
맵을 지우면 ExternalActors 안의 액터 파일 4개도 같이 지워질 거예요. 이 폴더는 콘텐츠 브라우저에 안 보여요. 다 지운 뒤에 말해 주시면 남아 있는지 확인해 드릴게요.
총구 이펙트는 20:14 이주 로그에 8개 모두 "migrated successfully"로 찍혀 있어서 문제없어요.
📓 일지 기록: data\2026\09\19.js (20260919-004에 트러블슈팅 추가)
그냥 나이아가라로 만든 파일만 옮겨도 되냐?
Checked what the converted effects depend on
네, Converted 두 개만 골라서 이주하면 돼요. 원본 캐스케이드 P 파일은 필요 없어요.
변환된 파일이 무엇을 쓰는지 직접 확인해 봤어요. 원본 P_는 안 쓰고 머티리얼만 써요.
옮길 파일 같이 따라오는 머티리얼
PBlood_Splat_Cone_Converted M_Blood_C, M_Dirt_A, M_Smoke_A, M_Snow
P_Default_Converted M_Glow_C, M_Glow_C_Inst, M_Rock_B, M_Smoke_Wisp
Migrate는 이 머티리얼들과 머티리얼이 쓰는 텍스처까지 자동으로 넣어요. 따로 고를 필요는 없고, Asset Report에서 체크가 다 돼 있는지만 보면 돼요.
⚠️ M_Glow_C_Inst는 파편 이펙트가 쓰는 머티리얼 인스턴스라서 꼭 있어야 해요. 아까 지워도 된다고 한 instance(ToReallmoveParticle_HLODLayer_Instanced)와 이름이 비슷하니까 헷갈리지 마세요. ToReallmoveParticle로 시작하는 파일만 지우면 돼요.
저거도 방법이긴함? 근데 일단 저거 두개만 옮길게 그럼
네, 맵 트릭도 맞는 방법이에요. 이번에는 맵을 World Partition으로 만들어서 안 됐을 뿐이에요.
방법 편한 경우
에셋 직접 Migrate 옮길 게 몇 개 안 될 때. 지금처럼 두 개면 이게 제일 빨라요. 여러 폴더에 흩어진 에셋도 Ctrl로 같이 골라서 한 번에 옮길 수 있어요.
빈 맵에 배치 → 맵째 Migrate 소품이나 이펙트처럼 눈으로 보면서 여러 개를 고를 때 편해요. 대신 맵은 Empty Level로 만들어야 하고, 옮긴 뒤에 맵을 지워야 해요.
두 방법 모두 딸린 머티리얼과 텍스처는 Migrate가 알아서 챙겨요. 차이는 옮길 에셋을 고르는 방식뿐이에요.
두 개 옮기고 나서 할 일은 이거예요.
BPMainPlayerCharacter → PistolWeapon, RifleWeapon → Details에서 Effect 검색
Blood Effect에는 P_Blood_Splat_Cone_Converted를 넣어요.
Impact Effect에는 P_Default_Converted를 넣어요.
ToReallmoveParticle로 시작하는 파일들과 NewFolder를 지워요.
PIE에서 벽을 쏘면 파편, 몬스터를 쏘면 피가 나오는지 확인해요.

안녕하세요! 이번 글은 좀 깁니다. 스태미나 하나 넣으려다가 증강까지 손대고, 이펙트를 붙이다가 파티클 시스템 두 종류를 배우고, 브랜치를 합쳤더니 레벨 하나가 통째로 날아가 있었고, 마지막엔 총이 벽을 뚫고 나가는 걸 잡느라 캐릭터 구조를 다시 생각했거든요.
순서대로 이렇게 다룹니다.
런앤히트 느낌을 내고 싶어서 달릴 때만 스태미나가 닳는 방식으로 정했습니다. 수치는 이렇게 잡았어요.
| 값 | 숫자 | 의미 |
|---|---|---|
| 최대 스태미나 | 100 | 시작값 |
| 달릴 때 소모 | 초당 25 | 약 4초 달리면 바닥 |
| 회복 | 초당 25 | 0에서 100까지 약 4초 |
| 회복 대기 | 1초 | 멈추고 1초 뒤부터 회복 |
| 다시 달리기 최소치 | 20 | 0 근처에서 켜졌다 꺼졌다 하는 것 방지 |
| 갱신 간격 | 0.05초 | 초당 20번 |
처음엔 당연히 Tick에서 처리할 줄 알았는데, 생각해 보면 스태미나가 변하지 않는 동안에는 계산할 게 없습니다. 그래서 타이머로 했어요.
//달리기를 시작하면 줄이기 시작하고 멈추면 회복을 시작해야 하므로 타이머가 쉬고 있으면 깨움
if (!GetWorldTimerManager().IsTimerActive(StaminaTimerHandle))
{
GetWorldTimerManager().SetTimer(StaminaTimerHandle, this,
&AMainPlayerCharacter::UpdateStamina, STAMINA_UPDATE_INTERVAL, true);
}
//가득 찼고 달리지도 않으면 더 할 일이 없으니 타이머를 멈춤 다음 달리기 때 SetSprinting이 다시 켬
if (CurrentStamina >= MaxStamina && !bIsSprinting)
{
GetWorldTimerManager().ClearTimer(StaminaTimerHandle);
}
이게 제일 헷갈렸던 부분인데요, FTimerHandle은 타이머 그 자체가 아닙니다. 타이머는 월드의 타이머 매니저가 들고 돌리고, 핸들은 그걸 가리키는 번호표예요.
| 하는 일 | 코드 |
|---|---|
| 켜기 | SetTimer(핸들, ...) ← 번호표에 번호가 적힘 |
| 돌고 있나 확인 | IsTimerActive(핸들) |
| 끄기 | ClearTimer(핸들) ← 번호표가 무효가 됨 |
그래서 "번호표를 멤버 변수가 아니라 지역 변수로 두면 어떻게 될까요?"라는 질문을 받았을 때 저는 "스태미나 기록을 못 가져와서 못 뛴다"고 답했는데, 완전히 빗나갔습니다(?). 정답은 이랬어요.
SetTimer는 지역 번호표로도 정상 작동합니다. 타이머는 매니저가 들고 있으니까요.UpdateStamina에서 끌 번호표가 없어서 영원히 돕니다.즉 "못 뛴다"가 아니라 "타이머가 쌓이고 안 꺼진다"였습니다. 번호표를 멤버로 둔 이유는 여러 함수가 같은 타이머를 가리켜야 하기 때문이었어요.
//마지막으로 스태미나를 쓴 시각
float LastStaminaUseTime = -100.0f;
저는 "시작할 때 100이니까 초과 회복을 막으려고 -100인가?" 했는데 아니었습니다. 초과 회복은 따로 막고 있었어요.
const float ClampedStamina = FMath::Clamp(NewStamina, 0.0f, MaxStamina);
-100의 진짜 역할은 회복 대기 판정입니다.
if (CurrentTime - LastStaminaUseTime < STAMINA_REGEN_DELAY) //마지막으로 쓴 지 1초가 안 됐으면
{
return; //회복하지 마
}
시작값이 0이면 게임 시작 시각도 0이라 처음 1초 동안은 "방금 썼다"가 되어 회복이 막힙니다. -100이면 "아주 옛날에 썼다 = 아직 안 썼다"가 되어 대기에 안 걸려요.
그리고 한 가지 더 헷갈렸던 것. 저는 이 값이 -100에서 -87, -55처럼 계속 줄어드는 줄 알았는데, 변하는 건 현재 시각이고 이 값은 뛸 때만 "지금"으로 덮어써집니다.
| 게임 시각 | 상황 | 기록값 | 차이 |
|---|---|---|---|
| 0 | 시작 | -100 | 100 |
| 5.00 | 뜀 | 5.00 | (뛰는 중) |
| 7.00 | Shift 뗌 | 7.00 | 0 |
| 8.00 | 쉬는 중 | 7.00 | 1.00 → 회복 시작 |
이런 방식을 타임스탬프(시각 기록)라고 부르더라고요. 알고 보니 우리 총의 연사 간격도 똑같은 패턴이었습니다.
//WeaponBase 연사 간격 판정
return (GetWorld()->GetTimeSeconds() - LastFireTime) >= GetFireInterval();
타이머를 따로 안 돌리고 시각 하나만 저장해 두고 뺄셈 한 번으로 판단하는 거죠. 나중에 스킬 쿨타임 만들 때도 그대로 쓰면 될 것 같아요.
발사를 막는 조건이 "Shift를 누르고 있나"만 보고 있었습니다. 그래서 가만히 선 채로 Shift만 눌러도 총이 안 나갔어요. 스태미나는 안 줄어드는데 사격만 막히는, 좀 억울한 상황이죠.
소모 조건과 발사 금지 조건이 결국 같은 판정이라 함수 하나로 묶었습니다.
//달리기 키를 누른 채 실제로 움직이고 있는지
//스태미나 소모(UpdateStamina)와 발사 금지(FireCurrentWeapon)가 같은 기준을 쓰도록 한 곳에 둠
//SizeSquared2D는 위아래(Z)를 뺀 수평 속도의 길이를 제곱한 값 0보다 큰지만 보면 되므로 제곱근 계산을 아낌
bool AMainPlayerCharacter::IsSprintMoving() const
{
return bIsSprinting && GetVelocity().SizeSquared2D() > KINDA_SMALL_NUMBER;
}
SizeSquared2D도 이번에 처음 봤는데요,
여기서 배운 것: 변수로 저장할 값과 함수로 계산할 값을 나누는 기준이요.
| 예 | 왜 | |
|---|---|---|
| 변수 | bIsSprinting | 입력이 들어올 때만 바뀜 → 바뀌는 순간을 안다 |
| 함수 | IsSprintMoving() | 속도는 매 프레임 바뀜 → 저장하면 틀린 값이 남는다 |
무기 파츠(총구·탄창 등)에 스태미나를 넣는 건 아무래도 이상하잖아요. 총이 체력을 올려주는 느낌이라(?). 스태미나는 몸 능력이니 증강 쪽이 맞다고 판단했습니다.
이미 있는 체력 증가 증강과 똑같은 구조로 만들었어요.
//스태미나 증가
void UStaminaUpSkill::Apply()
{
//스태미나는 플레이어 캐릭터에만 있음 몬스터가 이 증강을 얻는 일은 없지만 혹시 몰라 플레이어가 아니면 무시
AMainPlayerCharacter* Player = Cast<AMainPlayerCharacter>(GetOwnerActor());
if (!Player)
{
return;
}
Player->IncreaseMaxStamina(STAMINA_UP_AMOUNT);
}
여기서 고민했던 게 "최대 스태미나를 어디에 둘 것인가"였습니다. 체력·공격력·방어력은 공용 스탯 컴포넌트에 있는데, 스태미나를 거기 넣으면 몬스터도 안 쓰는 스태미나를 들고 다니게 됩니다. 그래서 스태미나는 플레이어에 그대로 두고, 스킬이 플레이어에게 "올려 줘"라고 부탁하는 모양으로 했어요.
//최대 스태미나를 늘리고 늘어난 만큼 채움 스태미나 증가 증강이 부름
//체력 증가 증강과 같은 규칙 최대치만 늘리면 게이지 비율이 갑자기 줄어 보여서 같이 채워줌
void AMainPlayerCharacter::IncreaseMaxStamina(float Amount)
{
MaxStamina += Amount;
SetCurrentStamina(CurrentStamina + Amount);
}
이러면서 MAX_STAMINA 상수가 BASE_MAX_STAMINA(시작값) + MaxStamina(변수)로 갈라졌습니다. 증강으로 변할 값은 더 이상 상수일 수 없으니까요.
확률은 공격력·방어력·체력(가중치 20)보다 낮은 12로 뒀습니다. 달리기 보조라서 너무 자주 나오면 곤란하거든요. 반복 획득은 허용(먹을 때마다 +25)이고요.
회의에서 정한 걸 한 번에 구현했습니다. 덩치가 커서 표로 정리할게요.
| 항목 | 내용 |
|---|---|
| 파츠 칸 | 공용 3칸(총구·탄창·조준기) + 소총 전용 2칸(개머리판·앞손잡이) |
| 효과 | 총구·조준기 = 공격력 / 나머지 = 연사력 |
| 등급 | Level1~4, Boss |
| 강화 | +5까지, 실패하면 낮은 확률로 파괴 |
| 재화 | 꿈의 조각 (일반 1~3, 정예 5~10, 보스 50) |
| 드롭 | 일반 2%, 정예 10%, 보스는 보스 파츠 확정 |
총마다 칸이 다른 건 부모에 기본 3칸을 두고 소총이 덧붙이는 식으로 했어요.
//소총만 가진 칸을 공용 칸에 더함
TArray<EWeaponPartSlot> URifleWeapon::GetPartSlots() const
{
TArray<EWeaponPartSlot> Slots = Super::GetPartSlots();
Slots.Add(EWeaponPartSlot::Stock);
Slots.Add(EWeaponPartSlot::Foregrip);
return Slots;
}
수치 표를 배열로 두다 보니 이런 줄이 생겼는데, 처음엔 뭔지 몰랐습니다.
static_assert(UE_ARRAY_COUNT(PART_PRICE_BY_TIER) == static_cast<int32>(EWeaponPartTier::Boss) + 1,
"PART_PRICE_BY_TIER needs one value per tier");
static_assert: 컴파일할 때 검사합니다. 거짓이면 빌드가 실패해요. 누가 등급을 하나 추가하고 가격 표를 안 늘리면 게임을 켜기도 전에 잡힙니다. 없으면 실행 중에 표 밖을 읽어서 이상한 값이 나오거나 크래시가 나겠죠.static_cast<int32>(enum): enum class는 실수로 숫자처럼 쓰는 걸 막으려고 자동으로 int가 되지 않습니다. 그래서 표의 번호로 쓸 때 직접 바꿔 줘야 해요.Cast<타입>(...)와는 다릅니다. 그건 실행 중에 "진짜 그 클래스 맞아?"를 확인하고 아니면 nullptr을 돌려주는 거고요.복사본을 넘기는 이유
FWeaponPart NewPart = Part;
Parts.Add(NewPart);
//배열 안의 참조 대신 복사본을 넘김 알림을 받은 쪽이 그 자리에서 파츠를 또 넣어도 참조가 깨지지 않게
OnPartAcquired.Broadcast(NewPart);
배열에 원소를 추가하면 내부적으로 더 큰 메모리로 이사를 갈 수 있습니다. 그때 원래 자리를 가리키던 참조는 엉뚱한 곳을 보게 되고요.
판매가를 지우기 전에 계산하는 이유
//지우기 전에 값을 계산해둠 지운 뒤에는 그 번호에 다른 파츠가 들어옴
const int32 SellPrice = GetSellPrice(Parts[PartIndex]);
Parts.RemoveAt(PartIndex);
지운 뒤에 계산하면 다음 파츠 가격을 주거나, 마지막 번호였으면 없는 칸을 읽습니다.
인벤토리 → 무기는 한 방향
끼운 파츠는 무기에게 복사해서 넘깁니다. 무기는 인벤토리를 전혀 모르고요. 저는 처음에 "순환 참조 방지"라고 답했는데, 정확히는 의존 방향을 한쪽으로만 두기 위함이었어요. 이러면 나중에 몬스터가 같은 무기를 들어도 무기 코드를 안 고쳐도 됩니다.
① 초반부터 터지게
//강화에 실패했을 때 파츠가 부서질 확률 목표 단계 순서 +1부터 부서질 수 있음
//실패한 뒤에 한 번 더 굴리므로 한 번 시도할 때 실제로 부서질 확률은 (1 - 성공 확률) x 이 값
const float ENHANCE_DESTROY_CHANCE[] = { 0.05f, 0.08f, 0.12f, 0.18f, 0.25f };
| 목표 | 성공 | 실패 시 파괴 | 시도 1번당 실제 파괴 |
|---|---|---|---|
| +1 | 90% | 5% | 0.5% |
| +3 | 50% | 12% | 6% |
| +5 | 15% | 25% | 21.3% |
② 강화해도 판매가는 그대로
강화에 쓴 조각을 되팔아서 회수하면 재미가 없어서, 판매가는 등급 가격의 30% 고정으로 바꿨습니다.
처음엔 소총을 처음부터 들고 시작했는데, L2를 깨서 L3가 열리면 로비 상점에서 구매하는 방식으로 바꿨어요. 파츠도 소총을 가진 뒤에만 나오게 했고요.
핵심은 이 함수 하나였습니다.
//인벤토리 주인인 플레이어가 가진 무기
UWeaponBase* UInventoryComponent::FindWeapon(EWeaponSlot Weapon) const
{
AMainPlayerCharacter* OwnerPlayer = Cast<AMainPlayerCharacter>(GetOwner());
//소총 컴포넌트는 사기 전에도 숨겨진 채로 있어서 가졌는지 따로 확인함
//여기서 한 번 거르면 파츠 드롭 구매 장착이 전부 가진 총 기준으로 맞춰짐
if (!OwnerPlayer || !OwnerPlayer->HasWeapon(Weapon))
{
return nullptr;
}
return OwnerPlayer->GetWeaponInSlot(Weapon);
}
한 곳만 고쳤는데 드롭·구매·장착이 전부 "가진 총 기준"으로 맞춰지는 게 기분 좋았습니다. 그리고 여기서 숨은 버그를 하나 잡았어요. 보유 무기 목록이 Transient(저장 안 함)라서 레벨을 넘기면 산 소총이 사라지고 있었습니다. 파츠와 같은 규칙으로 저장하게 고쳤어요.
| 난이도 | 죽으면 | 시간 초과 |
|---|---|---|
| 쉬움 | 증강·파츠·조각·무기 전부 유지 → 로비 | 같음 |
| 보통 | 레벨 들어오기 전 상태 → 로비 | 같음 |
| 어려움 | 새 게임 (L1부터) | 보통과 같음 |
처음엔 "죽으면 같은 레벨 재시작"으로 만들었는데, 생각해 보니 로비로 가야 파츠를 사거나 강화하고 다시 도전할지 고를 수 있잖아요. 그래서 전부 로비로 가게 바꿨습니다.
그랬더니 재밌는 일이 생겼어요. 쉬움·보통에서 죽는 것이 시간 초과와 완전히 같은 처리가 된 겁니다. 그래서 따로 만들어 뒀던 사망 처리 함수를 지웠습니다.
//플레이어가 죽은 뒤 이어서 진행
void UDreamVeilGameInstance::ContinueAfterDeath()
{
//어려움은 죽으면 끝 새 게임이 진행도 증강 인벤토리 무기를 전부 비우고 로비부터 다시 시작함
if (Difficulty == EGameDifficulty::Hard)
{
StartNewGame();
return;
}
//쉬움 보통은 시간 초과와 똑같이 로비로 감 로비에서 파츠를 사고 강화하거나 바로 다시 도전할지 고름
FailCurrentLevel();
}
기능을 추가하는 것보다 같은 규칙을 한 곳으로 합치는 것이 더 기분 좋다는 걸 알았습니다. 코드가 줄었는데 되는 건 늘었어요.
드디어 UI를 시작했습니다. 흐름은 이렇게 잡았어요.
플레이어가 죽음 → OnPlayerDied 이벤트 → 컨트롤러가 받아서 위젯 띄움 → 버튼 → ContinueAfterDeath
플레이어는 "죽었다"고 알리기만 하고, 화면을 띄우는 건 컨트롤러가 합니다. 입력과 화면은 컨트롤러 담당이니까요.
//플레이어가 죽었을 때
void AMainPlayerController::ShowGameOver()
{
UUserWidget* GameOverWidget = CreateWidget<UUserWidget>(this, GameOverWidgetClass);
//...
GameOverWidget->AddToViewport();
//버튼을 마우스로 누를 수 있게 UI 전용 입력으로 바꾸고 커서를 보여줌
FInputModeUIOnly InputMode;
InputMode.SetWidgetToFocus(GameOverWidget->TakeWidget());
SetInputMode(InputMode);
bShowMouseCursor = true;
}
여기서 함정이 하나 있었습니다. 입력 모드는 레벨이 바뀌어도 남습니다. 게임 오버 화면에서 UI 전용으로 바꾼 채 로비로 가면, 로비에서 캐릭터가 안 움직이는 사태가 벌어져요. 그래서 컨트롤러가 시작될 때 되돌려 놓습니다.
//게임 오버 화면에서 UI 전용 입력으로 바꾼 채 레벨을 옮기면 입력 모드는 새 레벨에도 그대로 남아서 조작이 안 됨
SetInputMode(FInputModeGameOnly());
총구 화염·피·파편을 붙이는 작업이었는데, 여기서 제일 많이 넘어졌습니다.
Fab에서 무료 이펙트 팩을 받아 왔는데 무기의 이펙트 칸에 아예 안 뜨더라고요. 알고 보니 언리얼에는 파티클 시스템이 두 종류가 있었습니다.
| 종류 | 접두사 | 비고 |
|---|---|---|
| 캐스케이드(Cascade) | P_ | UE4 시절 방식 |
| 나이아가라(Niagara) | NS_ | UE5에서 쓰는 방식 |
우리 코드는 UNiagaraSystem을 받으니 캐스케이드는 목록에 나오지도 않죠. 다행히 엔진에 Cascade To Niagara Converter(베타) 플러그인이 있어서 변환해서 썼습니다. 우클릭 → Convert To Niagara System이면 _Converted 파일이 생겨요.
변환하고 옮겼는데 이펙트가 이상하게 보였습니다. 에디터 로그를 보니 답이 있었어요.
LoadErrors: Warning: While trying to load package .../NS_MuzzleFlash,
a dependent package .../Materials/M_Fire was not available.
이펙트만 옮기고 그게 쓰는 머티리얼·텍스처는 안 따라온 것이었습니다. 이주(Migrate)할 때 딸려 오는 목록을 확인해야 한다는 걸 이때 배웠어요.
미르님께 배운 꿀팁이 있었습니다. 빈 맵에 필요한 에셋을 배치하고 맵을 통째로 이주하면 딸린 게 다 따라온다는 거였는데요, 이게 안 먹혔습니다. 로그를 보니 맵과 HLOD 레이어, 액터 파일만 넘어가고 정작 파티클이 빠져 있었어요.
원인은 빈 맵을 World Partition으로 만든 것이었습니다. World Partition 맵은 배치한 액터를 맵 파일 안에 넣지 않고 __ExternalActors__ 폴더에 따로 저장하거든요. 이주는 맵 파일이 참조하는 것만 따라가서, 액터가 참조하는 파티클은 빠진 거죠.
이 트릭을 쓸 거면 맵을 Empty Level(World Partition 아님)로 만들어야 합니다.
총구 불꽃이 소총엔 어울리는데 권총엔 너무 컸습니다. "코드에서 배율을 주면 되지 않나?" 했는데 안 되더라고요. 엔진 코드를 뒤져 보니 두 가지 이유가 있었어요.
SnapToTarget 방식은 이펙트를 월드 크기 1배로 붙입니다. 총 크기가 전해지지 않아요.결국 나이아가라 에셋 안에서 이미터를 손봐야 했습니다. 권총용은 원본을 복제해 이미터를 몇 개 지워서 작게 만들고, 소총은 원본 그대로 쓰는 식으로 정리했어요.
참고로 User Parameters에 있던 Power, Width를 줄여 봤는데 크기와는 상관없는 값이었습니다. 게다가 되돌리기(↶) 버튼은 만든 사람이 넣어 둔 원래 값이 아니라 0으로 돌려버려서, 원본 프로젝트를 열어 값을 다시 적어 넣어야 했어요. (원래 값은 15와 12였습니다)
벽에 붙으면 총구가 벽 반대편으로 쑥 나갑니다. 원인은 단순해요. 애니메이션이 캐릭터 캡슐 밖으로 나가는데, 캡슐은 몸 크기라 총까지 막아주지 못하는 것이죠.
해결책을 세 가지 놓고 고민했습니다.
| 방법 | 장점 | 단점 |
|---|---|---|
| ① 캡슐 키우기 | 제일 쉬움 | 피격 범위가 넓어져서 "이게 왜 맞아?" 사태 |
| ② 콜리전을 애니메이션에 맞추기 | 제일 현실적 | 노가다. 지금 일정으론 불가능 |
| ③ 로비에선 무기 없는 캐릭터 쓰기 | 벽 뚫림 해결, 구현 간단 | 레벨마다 폰을 지정해야 함 |
③으로 갔습니다. 로비는 싸우는 곳이 아니니까요.
처음엔 로비용 캐릭터 블루프린트를 따로 만들고 맵 설정에서 지정하는 방식으로 했는데, 여기서 사고가 났습니다. 게임 모드의 Default Pawn Class를 바꿔 버린 것이죠. 게임 모드는 모든 레벨이 함께 쓰는 설정이라, 이대로면 전투 레벨에서도 무기 없는 캐릭터가 나옵니다(...).
그래서 아예 캐릭터가 스스로 판단하게 바꿨습니다.
//로비에서는 싸우지 않으므로 무기를 숨기고 사격도 막음
//맵마다 폰을 따로 지정하지 않고 캐릭터가 스스로 정함 게임모드나 World Settings를 건드릴 필요가 없음
if (const UDreamVeilGameInstance* DreamVeilGameInstance = GetGameInstance<UDreamVeilGameInstance>())
{
bCombatEnabled = !DreamVeilGameInstance->IsInLobby();
}
이러면 블루프린트도 하나, 맵 설정도 없음입니다. 새 맵(Endless)이 생겨도 신경 쓸 게 없고요.
여기서 하나 더 주의한 점. "레벨 맵이 아니면 전부 로비 취급"으로 만들면 테스트 맵에서도 총이 사라집니다. 그래서 로비 맵일 때만 끄도록 정확히 판단하게 했어요.
로비에서 맨손 자세를 넣으려고 애님 그래프를 봤더니, 컴파일 메시지가 이렇게 뜨더라고요.
Default Pose was visible but ignored
Blend Poses by Enum 노드는 enum 값마다 핀을 만드는데, EWeaponSlot에는 Pistol과 Rifle 둘밖에 없어서 Default Pose로 갈 일이 아예 없었던 겁니다.
enum에 None을 추가하면 되긴 하지만, 이 enum은 파츠 장착·드롭·상점·저장에 전부 쓰이고 있어요. "None용 파츠" 같은 게 생기면 곤란하죠. 그래서 무기를 들었는지 여부(bool)로 한 번 더 분기하는 방향으로 정했습니다. 맨손 애니메이션과 그래프 작업은 성민님이 맡아 주시기로 했어요.
팀원분이 만든 몬스터 코드를 읽다가 캡슐이 두 개인 걸 발견했습니다. "루트가 두 개인가?" 싶었는데, 파고들어 보니 그게 아니었어요.
CollisionCylinder (캡슐, 루트) ← ACharacter가 만들어 줌
├─ CharacterMesh0
└─ Capsule Collision (캡슐) ← 자식 클래스가 추가한 것, 루트가 아님
액터의 루트는 하나뿐이고, ACharacter를 상속하면 캡슐(루트)과 스켈레탈 메시, 이동 컴포넌트가 이미 들어 있습니다. 공식 문서에도 이렇게 적혀 있어요.
The CapsuleComponent is used for movement collision. (캡슐은 이동 충돌에 쓰인다)
즉 "충돌체를 루트로 만들고 메시를 그 밑에 붙인다"는 정석은 AActor나 APawn을 상속할 때의 이야기이고, ACharacter에서는 엔진이 이미 해 놓은 걸 설정만 하면 됩니다.
추가된 캡슐은 기본 설정이 겹침 감지 전용이라 총알도 통과하고, 코드 어디에서도 쓰지 않고 있었어요. 초기 설계(부모는 넓은 타입의 빈 칸, 자식이 실제 모양을 만듦)의 흔적이 남은 것으로 보였습니다.
그리고 진짜 버그도 하나 찾았습니다.
void AEliteMonster::BeginPlay()
{
//Super::BeginPlay()가 없음!
}
부모의 BeginPlay 안에서 컴포넌트들의 BeginPlay가 불립니다. 이게 빠지면 스탯·증강 컴포넌트가 초기화되지 않고, 이동 속도 설정도 안 되고, 블루프린트의 Event BeginPlay도 안 불려요. 한 줄 빠진 건데 영향이 꽤 넓더라고요.
여기서 배운 자세: "공식 문서에 적힌 것"과 "현업에서 흔히 쓰는 방법"을 구분해서 말하기. 예를 들어 "총알 판정은 메시의 Physics Asset으로 한다"는 공식 권장이 아니라 에픽 샘플(ShooterGame)에서 쓰는 방식이었습니다. 확인해 보니 정말로 캡슐은 무기 채널을 무시하고 메시가 막고 있더라고요.
미르님이 새로 만든 LV_4를 가져오려고 브랜치를 합쳤는데, 레벨이 통째로 날아가 보였습니다. 처음엔 "내 캐시 문제인가?" 했는데 아니었어요. 에디터 로그에 답이 있었습니다.
LogAssetRegistry: Error: Package is unloadable: .../LV_4.umap.
Reason: Invalid value for PACKAGE_FILE_TAG at start of file.
맵 파일을 열어 봤더니 이런 내용이 들어 있었어요.
version https://git-lfs.github.com/spec/v1
<<<<<<< HEAD
oid sha256:1cc6ee... size 548609
=======
oid sha256:c76971... size 84261
>>>>>>> feat/EndlessLV
.umap은 바이너리라 git이 내용을 합칠 수 없습니다. 그래서 양쪽 LFS 주소표를 충돌 표시와 함께 적어 두는데, 그게 그대로 커밋된 것이었어요. 에디터는 맵 대신 262바이트짜리 텍스트를 읽었으니 아무것도 못 여는 게 당연했죠.
배운 것 정리합니다.
C1 83 2A 9E입니다. 파일 크기가 수백 바이트면 의심해 보세요.그리고 이 참에 제 옛 브랜치 16개도 정리했습니다. 지우기 전에 브랜치 이름과 마지막 커밋 주소를 파일로 남겨 뒀어요. 커밋 주소만 있으면 git branch 이름 주소로 되살릴 수 있으니까요. (실제로 하나는 되살렸다가 다시 지웠습니다. 간 떨어질 뻔했어요)
① AddMappingContext(IMC, 0)의 0
우선순위입니다. 엔진 주석에 "우선순위가 높은 매핑이 먼저 적용되고, 입력을 먹으면 낮은 쪽은 막힌다"고 적혀 있어요. 지금은 IMC가 하나뿐이라 아무 값이나 같지만, 나중에 상점 UI용 IMC를 추가하면서 1을 주면 같은 키를 덮어쓸 수 있습니다. 참고로 이건 초기값이나 쓰레기값 방지와는 전혀 상관없었어요.
② 화면에 뜨는 빨간 경고
RAY TRACING GEOMETRY - ALWAYS RESIDENT MEMORY OVER BUDGET
레이 트레이싱용 데이터가 기본 한도(400MiB)를 넘었다는 경고입니다. 넘으면 메모리에서 뺐다가 다시 만들어서 끊길 수 있어요. 우리 게임은 레이 트레이싱을 쓸 일이 없어서 나중에 최적화할 때 끄기로 하고 넘겼습니다.
③ 언리얼에서 소리 자르기
외부 프로그램 없이도 됩니다. Waveform Editor 플러그인(기본 꺼짐)을 켜면 사운드에 Trim Fade 변환을 추가할 수 있어요. Start/End Time으로 자르고 Fade-Out으로 "틱" 소리를 막습니다. 원본을 건드리지 않는 방식이라 언제든 되돌릴 수 있고요. (총소리가 발사 간격보다 길면 소리가 겹쳐서 웅웅거리더라고요)
④ 머티리얼 인스턴스(MI)에 대해 잘못 알고 있던 것
"MI는 실시간 연산이라 게임 성능이 빨라진다"고 알고 있었는데 아니었습니다. 편집할 때 재컴파일이 없는 건 맞지만, 픽셀 하나를 그리는 계산량은 부모 머티리얼과 똑같아요. 실행 중 이점은 셰이더 개수가 줄어서 메모리·로딩이 가벼워지는 쪽입니다.
이번 구간에서 제일 크게 남은 건 기능 하나하나보다 "어디에 둘 것인가"를 고민한 시간이었습니다.
그리고 틀린 걸 틀렸다고 확인받는 과정이 생각보다 훨씬 도움이 됐습니다. 타이머 번호표도, -100도, MI 성능도, 루트가 두 개인 줄 알았던 것도 전부 제가 알던 것과 달랐거든요. 혼자였으면 "되니까 됐지"하고 넘어갔을 것들이에요.
레벨을 새로 만들어 주신 미르님, 애니메이션을 맡아 주신 성민님, 몬스터와 스포너를 만들어 주신 Kedis님 감사합니다. 덕분에 제 파트에 집중할 수 있었어요.
다음은 UI입니다. 게임 오버 화면은 뼈대가 섰으니 HUD, 로비 침대, 상점·인벤토리 순서로 붙일 예정이고요. 플레이 시간이 길어지면 저장 기능도 필요할 것 같아서 USaveGame도 알아보는 중입니다.
그럼 다음 글에서는 버튼을 눌렀을 때 진짜로 뭔가 일어나는 화면을 들고 오겠습니다. 오늘도 벽을 뚫지 않는 하루 되세요!