DungeonZero 어노말리 어빌리티 구현 회고

김세희·2026년 4월 1일

DungeonZero

목록 보기
1/3

배경 — 왜 이 어빌리티들을 만들었는가

DungeonZero는 "8번출구" 류의 이상현상 탐지 게임이다. 플레이어는 복도를 반복해서 걷다가 뭔가 달라진 것을 발견해야 한다. 이때 "달라진 것"을 만들어내는 주체가 어노말리 어빌리티다.

4인 멀티플레이를 전제로 하기 때문에 이상현상은 서버에서만 결정되어야 하고, 그 결과는 모든 클라이언트에 동일하게 보여야 한다. 또한 어노말리의 종류는 앞으로 계속 늘어날 것이므로 확장성도 요구된다.

이 두 조건(멀티플레이 복제 + 확장성)을 동시에 충족하기 위해 Unreal의 Gameplay Ability System(GAS) 을 채택했고, 각 이상현상 행동을 독립적인 UGameplayAbility 서브클래스로 구현했다.

현재 구현된 어빌리티는 다음 세 가지다.

클래스이상현상 유형
UDZGA_AnomalyScale오브젝트 크기가 달라짐
UDZGA_AnomalyRelocate오브젝트 위치가 바뀜
UDZGA_AnomalyPatrol오브젝트가 스스로 이동함

설계 원칙 — 메시에 종속되지 않는 범용 어빌리티 (와 그 한계)

이상현상 오브젝트는 의자, 표지판, 소화기 등 메시가 전부 다르다. 어빌리티가 특정 메시나 액터 타입에 종속되면 어노말리를 추가할 때마다 코드를 수정해야 한다.

이를 피하기 위해 두 가지 원칙을 지켰다.

① 어빌리티는 가능한 한 AActor*만 알고 있다
어빌리티 내부 로직은 GetAvatarActorFromActorInfo()로 얻은 AActor*에만 의존하는 것을 원칙으로 한다. AnomalyRelocateAnomalyPatrol은 이 원칙을 완전히 따르므로, 어떤 오브젝트에든 부여하면 동작한다.

단, AnomalyScale은 예외다. 스케일 변경은 SetActorScale3D를 호출해도 SetReplicatingMovement만으로는 클라이언트에 복제되지 않는다. 이를 해결하려면 UPROPERTY(ReplicatedUsing = OnRep_...) 전용 변수와 세터가 필요하고, 이 구현은 불가피하게 베이스 액터 클래스(ADZAnomalyActorBase)에 위치할 수밖에 없었다. 결과적으로 AnomalyScaleADZAnomalyActorBase를 캐스트해 SetAnomalyScale을 호출하는 형태로 구현되었다.

// DZGA_AnomalyScale.cpp 발췌
if (ADZAnomalyActorBase* AnomalyActor = Cast<ADZAnomalyActorBase>(TargetActor))
{
    AnomalyActor->SetAnomalyScale(NewScale);
}

이는 "멀티플레이 복제 요구사항"과 "범용성" 사이의 트레이드오프이며, 복제 정확성을 우선한 결과다.

어빌리티액터 종속성
AnomalyRelocateAActor*만 사용, 어떤 액터든 동작
AnomalyPatrolAActor*만 사용, 어떤 액터든 동작
AnomalyScaleADZAnomalyActorBase 캐스트 필요 (복제 요구사항)

② 변화량은 EditDefaultsOnly로 노출한다
크기 범위, 이동 반경, 이동 속도 같은 수치를 블루프린트에서 오버라이드할 수 있게 열어두었다. 덕분에 같은 어빌리티 클래스를 상속한 블루프린트 서브클래스를 만들기만 하면, 의자용·소화기용 등 오브젝트별로 다른 수치를 코드 변경 없이 적용할 수 있다.

// DZGA_AnomalyPatrol.h 발췌
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "DZ | Anomaly | Patrol")
float MinPatrolRadius = 100.f;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "DZ | Anomaly | Patrol")
float MaxPatrolRadius = 300.f;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "DZ | Anomaly | Patrol")
float PatrolSpeed = 100.f;

구현 포인트 1 — GetRandomReachablePointInRadius 대신 각도·거리 직접 계산

문제: UNavigationSystemV1::GetRandomReachablePointInRadius는 최소 이동 거리를 보장하지 않는다. 반환된 위치가 원점 바로 옆일 수 있어, 이상현상으로서의 시각적 변화가 없는 경우가 생긴다.

해결: 랜덤 각도(0~360°)와 랜덤 반경(Min~Max)을 직접 계산해 후보 위치를 생성한 뒤, ProjectPointToNavigation으로 NavMesh 위 유효 위치인지 검증하는 루프를 최대 30회 시도한다.

float RandomAngle  = FMath::FRandRange(0.f, 360.f);
float RandomRadius = FMath::FRandRange(MinRelocateRadius, MaxRelocateRadius);
FVector RandomOffset = FVector(
    RandomRadius * FMath::Cos(FMath::DegreesToRadians(RandomAngle)),
    RandomRadius * FMath::Sin(FMath::DegreesToRadians(RandomAngle)),
    0.f
);
FVector TestLocation = OriginLocation + RandomOffset;

FNavLocation NavLocation;
if (NavSystem->ProjectPointToNavigation(TestLocation, NavLocation)) { ... }

추가 검증 (Relocate 한정): ProjectPointToNavigation은 NavMesh 경계 밖의 위치도 가장 가까운 NavMesh 위치로 스냅한다. 이 경우 결과 위치가 원점 근처로 끌려와 최소 거리 보장이 깨진다. XY 거리를 한 번 더 검증해 이를 방지한다.

const float ActualDist2D = FVector::Dist2D(OriginLocation, NavLocation.Location);
if (ActualDist2D < MinRelocateRadius) { continue; }

구현 포인트 2 — Z값 보정 (피벗이 바닥 중심이 아닌 액터 대응)

문제: NavMesh 위의 점은 "바닥 표면" 좌표를 반환한다. 그러나 액터의 피벗이 오브젝트 중심에 있는 경우, 이 값을 그대로 SetActorLocation에 넘기면 액터가 바닥을 뚫거나 허공에 떠 있게 된다.

해결: 이동 전에 현재 위치와 바운딩 박스 최솟값의 Z 차이(PivotToBottom)를 미리 계산해두고, NavMesh 결과에 더해준다. 피벗 위치에 관계없이 "액터 바닥면이 NavMesh 표면에 닿도록" 보정한다. Relocate와 Patrol 양쪽에 모두 적용했다.

const FBox  ActorBox      = TargetActor->GetComponentsBoundingBox();
const float PivotToBottom = OriginLocation.Z - ActorBox.Min.Z;

// NavMesh 위치 계산 후
ResultLocation.Z += PivotToBottom;

구현 포인트 3 — MinScaleDelta (스케일 최소 변화량 보장)

문제: FMath::RandRange(MinScale, MaxScale)로 랜덤 스케일을 뽑으면 결과가 1.0(원래 크기)에 가까울 수 있고, 플레이어가 변화를 인지하지 못한다. 이상현상 게임에서 "변화가 있었지만 눈에 안 띄는" 상황은 게임 경험을 망친다.

해결: 1.0 기준으로 [MinScale, 1 - MinScaleDelta](축소) 또는 [1 + MinScaleDelta, MaxScale](확대) 두 구간으로 나눠 랜덤 선택한다. 두 구간이 모두 유효하면 RandBool()로 방향을 먼저 결정한 뒤 각 범위에서 뽑는다.

const bool bCanShrink = MinScale <= 1.f - MinScaleDelta;
const bool bCanGrow   = MaxScale >= 1.f + MinScaleDelta;

if      (bCanShrink && bCanGrow) NewScale = FMath::RandBool()
    ? FMath::RandRange(MinScale,            1.f - MinScaleDelta)
    : FMath::RandRange(1.f + MinScaleDelta, MaxScale);
else if (bCanShrink)             NewScale = FMath::RandRange(MinScale, 1.f - MinScaleDelta);
else if (bCanGrow)               NewScale = FMath::RandRange(1.f + MinScaleDelta, MaxScale);
else                             /* 범위 오류 → 어빌리티 종료 */;

MinScale, MaxScale, MinScaleDelta 세 값을 EditDefaultsOnly로 노출해 블루프린트 서브클래스마다 다른 변화 폭을 설정할 수 있다.


정리

문제원인해결책
최소 이동 거리 미보장GetRandomReachablePoint의 거리 하한 없음각도·반경 직접 계산 + 루프 검증
NavMesh 스냅 후 거리 붕괴ProjectToNavigation의 경계 스냅XY 거리 재검증으로 2차 필터
액터가 허공에 뜨거나 땅속에 묻힘NavMesh 점 ≠ 액터 피벗 위치PivotToBottom 오프셋 보정
스케일 변화가 눈에 안 띔랜덤 결과가 1.0 근처로 수렴 가능MinScaleDelta로 데드존 제거
메시마다 코드 수정 필요어빌리티가 특정 액터 타입에 종속AActor* 기반 + EditDefaultsOnly 노출

0개의 댓글