소울류 게임에서 자주 보이는 암살 기능은 상대 뒤에서 강한 데미지를 가하는 것이다. 이 기능이 필요한 이유는 단순히 앞에서 맞붙는 평면적인 전투에 변화를 주고, “뒤를 잡아 한 방에 끝낸다”는 재미 요소를 추가하기 위함이다.
설계 고민
암살 가능 여부를 누가 판단할지 두 가지 접근을 생각했다.
몹 주도 체크
몬스터가 스스로 상태, 시야, 청각, 플레이어와의 거리를 계산해 암살 가능 여부를 판단.
플레이어 주도 체크
플레이어가 일정 주기로 주변 몹들을 스캔하고, 몹의 상태를 확인해 암살 가능한 대상을 선택.
처음에는 1번 방식을 고민했지만, 결국 암살 기능의 주체는 플레이어다. 따라서 플레이어가 주도적으로 체크하고, 몹은 자기 상태(전투 중, 패트롤 중, 추적 중 등)만 제공하도록 구조를 바꿨다.
동작 방식
플레이어가 몬스터 뒤에서 일정 거리 이내에 접근하고, 몬스터가 플레이어를 인식하지 못했을 때 암살 아이콘을 노출한다.
아이콘 위치는 목 위치로 (BoneName : "neck_01")
이때 특정 키를 입력하면 암살 애니메이션이 발동
몬스터 방향을 플레이어에게 등을 지도록 회전
if (ACharacter* Victim = Cast<ACharacter>(BackstabTarget))
{
// 몬스터 → 플레이어 방향 벡터
FVector ToAttacker = (OwnerCharacter->GetActorLocation() - Victim->GetActorLocation()).GetSafeNormal2D();
// 그 방향을 바라보는 회전
FRotator VictimRot = ToAttacker.Rotation();
// Yaw에 180도 추가해서 등을 내주게 함
VictimRot.Yaw += 180.f;
Victim->SetActorRotation(VictimRot);
}
몬스터 체력만큼의 데미지를 입힌다.
단, 엘리트 몬스터나 보스는 암살이 불가능하거나, 한 번에 죽지 않는 예외 케이스를 고려한다.
구현 이슈
현재 몹은 상태에 따라 ABP(애님 블루프린트)에서 Dead 시퀀스를 재생한다. 문제는 암살로 적을 처치해도 일반 Dead 애니메이션이 실행된다는 점이었다.
오늘은 몹 AI와 플레이어 간 전투가 조금 더 박진감 있게 느껴지도록 거리 좁히기 스킬을 추가했다.
왜 이기능이 필요한가?
전투를 하다면 몹과 플레이어에 거리가 벌어지는 경우가 생긴다. 이때 몹이 단순히 플레이어에게 걸어 오는것보다, 순간적으로 거리를 좁히는게 더 전투에 재미를 가져다 줄 수 있기 때문이다. 흔히 등장하는 대쉬 공격 같은 느낌이다.
Enemy AI Behavior Tree
아래는 현재 Enemy BT 이다.
Random Task노드 활용 : 특정 확률로 타겟을 향해 걸어가거나, 특정 애니메이션과 스킬을 사용하면서 플레이어에게 빠르게 접근하도록 설정해 두었다.
EQS 활용 : 로 플레이어 주위를 돌다가 플레이어와 거리가 멀어지면 스킬을 사용하도록.
이렇게 해서 단순히 "걸어오기 -> 공격"이 아니라, "거리 계산 -> 순간 접근 -> 다시 패턴 반복" 하는 흐름을 가지도록 만들었다.
플레이어 스텟 Widget 변경
원래는 Playable / NonPlayable을 나눠서 HUD를 따로 붙였는데, 관리가 번거로웠다. 그래서 BaseCharacter에서 공통 처리하도록 구조를 변경했다. - HP / MP / Stamina를 통합 - 왼쪽 상단 HUD에 배치 덕분에 캐릭터 추가할 때 HUD를 따로 붙이지 않아도 된다.
보스 몬스터 추가
앞으로 다양한 스킬을 가진 보스 몬스터들을 추가할 계획이라 Data,BT,BP,몬스터 전용 스킬 등을 추가했다. 현재는 프로토타입 단계라 매쉬는 임시지만, 이후 어울리는 모델을 찾을 예정이다.
그 밖에.
아 그리고 이번에 이번에 언리얼엔진 버전 올렸다... 이유는 사용하려는 에셋이 5.4이후 버전이라..언리얼은 에셋이 업그레이드만 되지 다운그레이는 안된다는..
오늘의 핵심 변화: 몹 AI가 단순 추적만 하지 않고 “거리 좁히기 스킬” 사용 HUD 구조를 BaseCharacter 공통 처리로 개선 보스 몬스터 추가 및 확장 준비 프로젝트 버전 5.4로 업그레이드
언리얼 프로젝트를 진행하다 보면, 사용하려는 외부 에셋이나 플러그인 때문에 엔진 버전을 올려야 하는 상황이 종종 발생한다. 이번에는 내가 사용하려는 에셋이 5.4 전용이라서, 어쩔 수 없이 프로젝트를 5.3에서 5.4로 마이그레이션하게 되었다.
버전 업그레이드 절차
에디터 / Visual Studio 전부 종료
프로젝트 폴더 내 불필요 파일 삭제
Binaries/, Intermediate/, .vs/, *.sln
.uproject 우클릭 → Switch Unreal Engine Version… → 5.4 선택 후 확인
다시 .uproject 우클릭 → Generate Visual Studio project files
Visual Studio에서 .sln 열고 빌드 시도
2. 발생한 이슈
경로 문제: 기존 5.3에서 정상적으로 참조되던 일부 경로(플러그인/엔진 모듈 등)가 5.4 구조 변경으로 인해 인식을 못했다.
빌드 에러: 특정 매크로나 API가 5.4에서 deprecated 처리되면서, 컴파일 오류가 발생했다.
3. 해결 방법
경로 문제의 경우, Generate Project Files를 다시 수행하면 대부분 정리된다.
필요 시 Clean Solution → Rebuild를 시도해 캐시 문제까지 싹 정리했다.
4. 정리 ~~~~~~앞으로는 버전 업그레이드 전,
현재 프로젝트 백업
사용 중인 에셋이 호환 가능한지 확인 을 습관화해야겠다.
결국, 프로젝트를 새로운 버전으로 옮길 때는 단순히 "엔진만 바꾼다"로 끝나지 않는다. 특히 언리얼은 Binaries/Intermediate 캐시에 영향을 많이 받으므로, 폴더 삭제 → Switch Version → 재생성 순서를 꼭 지켜야 한다는 점을 다시 한번 체감했다.
몬스터 AI를 만들다 보니 단순히 플레이어를 보고 달려가서 공격하는 구조만으론 너무 기계적으로 보였다. 공격 모션 자체는 들어가지만, 전투가 뻣뻣하고 리듬감이 전혀 없다. 우리가 흔히 알고 있는 RPG게임의 전투는 이렇지 않다. 공격을 피했다가, 옆으로 게걸음 치듯 돌고, 다시 타이밍을 잡아 공격하는… 그런 살아있는 움직임.
그래서 EQS(Environment Query System)를 쓰기로 했다. EQS는 주변에 여러 후보 지점을 생성하고, 조건에 맞는 최적의 위치를 선택하게 한다. 즉, “이 상황에서 어디로 움직여야 전투가 자연스러워질까”를 AI 스스로 판단할 수 있는 구조.
사용방법
1. AI → Env Query 생성
그러면 아래와 같은 기능을 확인 할 수있다.
- Circle, Grid, Cone 등등
나는 플레이어 주변을 원형으로 돌도록 만들고 싶어서 Circle을 선택했다.
2. Add Test → 조건 설정
- 예: Distance(거리)를 조건으로 추가
3. EQS Test Pawn 생성
- 시각적으로 어떻게 동작하는지 확인하기 위함
그런 다음에 해당 BP에 아까 만들어 놓은 EQS를 세팅한다.
4. EnvQueryContext_BlueprintBase 생성
Query의 기준점을 정의하기 위해 필요
BP에서 GetActorOfClass 노드 사용 → Player Start 액터를 리턴하도록 세팅
Query에 대상을 Player Start 액터로 지정한다는 의미이다. (스샷에는 없지만 실행 링크까지 연결 해야함)
5. Circle Center에 Env Query 세팅
이렇게 하면 플레이어를 기준으로 Circle 포인트가 생성된다.
결과 확인
원형 구 위의 숫자는 점수다.
이 점수를 기준으로 액터가 어떤 위치로 이동할지 결정할 수 있다.
Behavior Tree에서Run EQS Query를 실행하고, Run Mode를 조정해서 이 점수를 활용한다.
게임에서 결제는 단순히 “API 한 번 호출 → 끝”이 아니다. 마켓에 요청을 던지고, 유저가 지문이나 비밀번호를 입력하고, 결과가 서버로 돌아와 아이템까지 지급돼야 비로소 하나의 플로우가 완성된다.
문제는 여기서 시간이다.
네트워크가 끊기면?
서버가 지연되면?
유저가 결제 창 켜놓고 화장실 다녀오면?
이 모든 상황에서 앱이 멈추지 않게 워치독(Watchdog Timer, WDT) 같은 안정장치가 필요했다. 근데 처음 단순히 타임아웃만 걸어놓으니까 진짜 난리가 났다.
“정상 결제인데 WDT가 실패 처리해버린다…” QA에서 이런 버그가 계속 터졌다. 서비스 상황에서 이런 일이 나면 유저 경험도 박살, 매출도 타격이다.
단순 타임아웃의 함정
내가 처음 만든 워치독은 정말 단순했다.
결제가 시작되면 N초 안에 결과가 안 오면 실패 처리
네트워크 문제는 잘 잡아줬다. 근데 문제는 유저 입력이었다.
PC, 웹결제, 구글 스토어, 애플 스토어, 인증 방식이 다 다르다 보니, 유저가 입력에 오래 걸리면 결제가 정상 진행 중인데도 WDT가 끊어버렸다. 이건 UX 최악이었다.
그때 깨달았다. GPT에게 가이드를 받았다. “타이머 하나로는 답이 안 된다. 결제 과정을 단계별로 쪼개야 한다.”
Phase-aware 설계로 전환
결제를 흐름대로 나눠보니 딱 3단계가 보였다.
Phase A – 요청(Requesting) 서버와 마켓에 결제 요청을 던지는 구간 → 네트워크 문제에 취약 → WDT ON
Phase B – 사용자 입력(User Input) 지문, 비번, 앱스토어 팝업에서 유저가 응답하는 구간 → 언제 끝날지 모름 → WDT OFF
Phase C – 결과 처리(Processing) 마켓 콜백 수신 → 서버 검증 → 아이템 지급 → 네트워크·서버 이슈 대응 필요 → WDT ON
즉,
Phase A, C: 워치독 켜두기
Phase B: 워치독 꺼두기
이렇게 “Phase-aware”하게 설계하는 게 답이었다.
코드로 옮기면
구현은 상태 머신처럼 심플하게 짰다.
enum PurchasePhase
{
None,
PrecheckRpc, // 서버 사전 체크
LaunchingSheet, // 결제창 띄우는 중
AwaitingUserInput, // 결제창에서 사용자 입력 (타임아웃 금지)
VerifyingReceiptRpc, // 영수증 검증
AcknowledgeOrConsume, // 승인/소모 처리
Finished
}
private PurchasePhase _phase = PurchasePhase.None;
private float _lastActivityTime;
public void TouchActivity() => _lastActivityTime = Time.realtimeSinceStartup;
//구현부
IEnumerator PurchaseWatchdog()
{
while (_phase != PurchasePhase.Finished)
{
yield return new WaitForSecondsRealtime(0.5f);
if (_phase == PurchasePhase.AwaitingUserInput
continue; // 타임아웃 금지
var limit = GetPhaseTimeout(_phase);
if (limit <= 0f) continue;
float elapsed = Time.realtimeSinceStartup - _lastActivityTime;
if (elapsed > limit)
{
HandleSoftTimeout(_phase); // 부드럽게 중단/재시도/복구 안내
yield break;
}
}
}
_phase = PurchasePhase.PrecheckRpc;
TouchActivity();
네트워크/서버 문제는 그대로 잡아주면서, 유저 입력 시간은 존중할 수 있게 된 셈이다.
결과와 배운 점
유저가 입력을 오래 해도 결제가 끊기지 않았다 → UX 개선
네트워크나 서버 지연은 여전히 워치독이 처리했다 → 안정성 강화
결제 QA 시나리오를 단계별로 나눠서 테스트하기 좋아졌다 → 운영 효율 증가
무엇보다 느낀 건, 워치독은 단순 타이머가 아니라 맥락(Context)을 아는 감시자여야 한다는 거였다.
이 경험은 단순히 결제 로직에만 국한되지 않는다. 멀티스텝으로 진행되는 모든 게임 시스템(예: 매칭, 다운로드, 인증)에 적용할 수 있는 사고 방식이다. 그리고 이런 구조적 문제 해결 경험은 이직 면접에서 “나는 단순 기능 구현자가 아니라 서비스 전체 안정성을 고민하는 개발자다”라는 걸 보여줄 수 있는 좋은 무기가 된다.
마무리
결제 워치독을 Phase-aware 방식으로 바꾼 건 작은 개선처럼 보일 수 있다. 하지만 실제 서비스 현장에서는 유저 경험 + 매출 + QA 효율을 동시에 챙긴 사례였다.
결국 이걸 하면서 내가 얻은 건 “코드를 짠다”를 넘어서, 게임 서비스 전체를 안정적으로 굴릴 수 있는 설계 사고였다.
테스트 시나리오(체크리스트)
결제창 미출력(LaunchingSheet soft timeout)
결제창에서 장시간 대기(입력 단계 타임아웃 없음 확인)
영수증 검증 지연(Verifying soft timeout → 재시도)
승인/소모 지연(Ack/Consume soft timeout → 안내)
Android PENDING / iOS Deferred 장기 대기 → 안내 유지, 복구 후 정상 반영
말 그대로, 게임 엔진 전체를 관리하는 핵심 엔진 객체에 접근하기 위한 글로벌 핸들 느낌이라고 생각하면 될듯?
UEngine은 추상적인 엔진의 베이스 클래스이고, 실제 실행 중에는 UGameEngine같은 하위 클래스가 생성되어 GEngine에 할당된다.
언제 쓰는지
디버깅, 로그, 시스템 레벨 기능을 직접 호출할때 쓴다.
=> 주로 디버그, 프로토타입, 툴 제작할 때 활용
대표적 예시
AddOnScreenDebugMessage()
화면 위에 직접 텍스트를 띄워줌.
보통 플레이 중 디버깅을 할 때 "눈에 바로 보여야" 할 때 사용.
특징:
UI/HUD 위에 표시
에디터 PIE(Play In Editor) 환경에서 바로 확인 가능
key : -1 (새로운 라인에추가), 0(기존 라인에 덮어씌움)
출력되는 위치는 바꿀 수 없다.
=>
디버그/테스트만 GEgine쓰고, 실제 전역 컨텍스트는 UGameInstance, UWorld, GetGameInstance()->GetSubsystem<T>(), UEngineSubsystem/UGameInstanceSubsystem/UWorldSubsystem을 쓴다.
런타임에 쓰는 UObject나 Actor와 뭐가 다를까?
GEngine은 전역적으로 엔진 전체 관리 객체이고, Actor나 UObject처럼 게임 월드에 배치되는 개별 오브젝트가 아님,
특정 레벨에 속하지 않아서, 어디서든 접근이 가능한 점
Unity와 비교하면?
유니티에서 1:1로 대체할 전역 엔진 객체는 없는것 같다. 언리얼은 "큰 한 덩어리"를 전역 핸들로 접근하는 문화?가 있고, 유니티는 주요 시스템을 정적 클래스/네임스페이스로 분산한 느낌이다.