Unreal 개발 본 캠프 38일차

HappyCircle·2026년 1월 21일

Unreal 개발

목록 보기
55/163

📘 TIL – Pawn 직접 이동 & 드론 시스템 총정리

(CharacterMovement 없이) Enhanced Input · Possess 전환 · 6DOF · 인공중력(접지) · 애니메이션 토글 디버깅

목표: CharacterMovementComponent 없이 APawn에서 직접 이동/회전을 구현하고,
Enhanced Input(IMC/IA)으로 입력을 연결, E키로 드론 스폰/포제스 전환,
드론은 6DOF + UpDown + 인공중력/접지/에어컨트롤,
마지막으로 Pawn 기반 애니메이션(Idle/Walk/Interact 토글)까지 연결하면서
실제로 마주친 “왜 안 되지?”들을 로그로 원인 확정 → 해결한 기록.

🧭 오늘의 목표 & 요구사항

✅ 공통

CharacterMovementComponent 금지(또는 미사용)

물리로 움직이지 않기: Simulate Physics = false

이동/회전은 코드로 직접 제어

이동: AddActorLocalOffset, AddActorWorldOffset

회전: AddActorLocalRotation, SetActorRotation

✅ 지상 Pawn

WASD 이동(평면)

마우스 회전(Yaw) + 카메라 Pitch(스프링암)

E키 Interact (토글) / 드론 토글로 확장 가능

✅ 드론 Pawn

6DOF 느낌 이동(로컬 기준 전진/좌우)

Up/Down: Axis1D (Space/Shift)

Roll(Q/E 또는 별도)

인공 중력(-Z) + 접지 판정(Sweep/Trace)

공중 이동 제어 감소(AirControl)

텔레포트 복귀 후 상태 리셋

🧱 1) Pawn 구성 & “Simulate Physics = false” 의미

1-1. 컴포넌트 구성(지상 Pawn 예시)

ABasePawn::ABasePawn()
{
    PrimaryActorTick.bCanEverTick = true;
    SetActorTickEnabled(true);

    CapsuleComp = CreateDefaultSubobject<UCapsuleComponent>(TEXT("CapsuleComp"));
    SetRootComponent(CapsuleComp);
    CapsuleComp->InitCapsuleSize(42.f, 96.f);
    CapsuleComp->SetCollisionProfileName(TEXT("Pawn"));

    MeshComp = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("MeshComp"));
    MeshComp->SetupAttachment(CapsuleComp);
    MeshComp->SetRelativeLocation(FVector(0.f, 0.f, -88.f));
    MeshComp->SetRelativeRotation(FRotator(0.f, -90.f, 0.f));

    SpringArmComp = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArmComp"));
    SpringArmComp->SetupAttachment(CapsuleComp);
    SpringArmComp->TargetArmLength = 300.f;
    SpringArmComp->bUsePawnControlRotation = false;

    CameraComp = CreateDefaultSubobject<UCameraComponent>(TEXT("CameraComp"));
    CameraComp->SetupAttachment(SpringArmComp);
    CameraComp->bUsePawnControlRotation = false;

    // ✅ 과제 요구: 물리 시뮬 OFF
    CapsuleComp->SetSimulatePhysics(false);
    MeshComp->SetSimulatePhysics(false);
}

1-2. 핵심 개념 (중요)

Simulate Physics = false는 물리 엔진이 위치/속도를 주도하지 않는다

즉, 내가 Tick에서 직접 위치를 바꾸지 않으면 절대 안 움직임

“안 움직임”의 1차 원인 Top 4

Tick 꺼짐(PrimaryActorTick / SetActorTickEnabled)

입력이 캐시되지 않음(입력 함수가 비어있거나 바인딩 실패)

AddActorLocalOffset(..., true)에서 Sweep 충돌로 이동이 막힘

IMC가 적용되지 않아 SetupPlayerInputComponent가 안 호출됨(혹은 아예 Possess 안 됨)

🎛️ 2) Enhanced Input – IMC가 안 먹는 대표 실수 & 해결

2-1. PlayerController에서 IMC 적용(정석)

BeginPlay()에서 UEnhancedInputLocalPlayerSubsystem로 IMC 추가

Pawn/Drone 전환 시 IMC를 갈아끼우는 구조가 가장 깔끔함

void AP3DPlayerController::BeginPlay()
{
    Super::BeginPlay();
    ApplyIMC(PawnInputMappingContext);
}

void AP3DPlayerController::ApplyIMC(UInputMappingContext* IMC)
{
    UE_LOG(LogTemp, Warning, TEXT("[PC] ApplyIMC called. IMC=%s"),
        IMC ? *IMC->GetName() : TEXT("None"));

    ULocalPlayer* LocalPlayer = GetLocalPlayer();
    if (!LocalPlayer)
    {
        UE_LOG(LogTemp, Error, TEXT("[PC] LocalPlayer is null"));
        return;
    }

    UEnhancedInputLocalPlayerSubsystem* Subsystem =
        LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>();
    if (!Subsystem)
    {
        UE_LOG(LogTemp, Error, TEXT("[PC] EnhancedInput Subsystem is null"));
        return;
    }

    // (선택) 기존 컨텍스트 제거
    if (PawnInputMappingContext)  Subsystem->RemoveMappingContext(PawnInputMappingContext);
    if (DroneInputMappingContext) Subsystem->RemoveMappingContext(DroneInputMappingContext);

    if (IMC)
    {
        Subsystem->AddMappingContext(IMC, 0);
    }
}

2-2. “IMC가 안 먹음”을 로그로 확정하는 루트

ApplyIMC 로그가 안 찍힌다
→ “IMC 문제”가 아니라 BeginPlay 자체가 안 탔다

✅ 실제로 발생했던 원인

BP_PlayerController에서 BeginPlay 오버라이드

Parent BeginPlay 호출 누락

결과: C++ BeginPlay 실행 X → IMC 적용 X

✅ 해결

BP BeginPlay에서 Call to Parent Function 추가

교훈:
“입력 세팅 문제”처럼 보여도, 실제로는 라이프사이클(Parent 호출) 끊김일 때가 많다.

🕹️ 3) 입력 바인딩 & Tick 이동 구조 (지상 Pawn)

3-1. SetupPlayerInputComponent (중요 포인트)

UEnhancedInputComponent로 캐스팅

PlayerController가 들고 있는 InputAction을 Pawn에서 바인딩

Interact(E)는 단발이므로 Started 추천 (Triggered는 반복처럼 들어올 수 있음)

void ABasePawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

    UE_LOG(LogTemp, Warning, TEXT("[Pawn] SetupPlayerInputComponent CALLED"));

    if (UEnhancedInputComponent* EnhancedInput = Cast<UEnhancedInputComponent>(PlayerInputComponent))
    {
        if (AP3DPlayerController* PC = Cast<AP3DPlayerController>(GetController()))
        {
            UE_LOG(LogTemp, Warning, TEXT("[Pawn] Move=%s Look=%s Interact=%s"),
                *GetNameSafe(PC->MoveAction),
                *GetNameSafe(PC->LookAction),
                *GetNameSafe(PC->InteractAction));

            EnhancedInput->BindAction(PC->MoveAction, ETriggerEvent::Triggered, this, &ABasePawn::Move);
            EnhancedInput->BindAction(PC->MoveAction, ETriggerEvent::Completed, this, &ABasePawn::Move);

            EnhancedInput->BindAction(PC->LookAction, ETriggerEvent::Triggered, this, &ABasePawn::Look);

            EnhancedInput->BindAction(PC->InteractAction, ETriggerEvent::Started, this, &ABasePawn::Interact);
        }
        else
        {
            UE_LOG(LogTemp, Error, TEXT("[Pawn] Controller is not AP3DPlayerController"));
        }
    }
}

3-2. 입력 캐시 – “입력 함수는 값만 저장”

입력 콜백 안에서 이동/회전을 직접 하면
프레임/이벤트 타이밍 때문에 제어가 튀거나 중복되기 쉬움

그래서 “입력은 캐시”, “처리는 Tick에서”로 분리

void ABasePawn::Move(const FInputActionValue& Value)
{
    CachedMoveInput = Value.Get<FVector2D>();
}

void ABasePawn::Look(const FInputActionValue& Value)
{
    CachedLookInput = Value.Get<FVector2D>();
}

3-3. Tick에서 실제 이동/회전

이동: 로컬 기준 (Forward=X, Right=Y)

회전: Pawn Yaw 회전 + SpringArm Pitch 클램프

void ABasePawn::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    // ===== 이동 (평면) =====
    if (!CachedMoveInput.IsNearlyZero())
    {
        const FVector LocalDelta(
            CachedMoveInput.Y, // Forward -> Local X
            CachedMoveInput.X, // Right   -> Local Y
            0.f
        );

        AddActorLocalOffset(LocalDelta * NormalSpeed * DeltaTime, true);
    }

    // ===== 회전(Yaw) + 카메라 Pitch =====
    if (!CachedLookInput.IsNearlyZero())
    {
        const float YawDelta   = CachedLookInput.X * MouseSensitivity;
        const float PitchDelta = CachedLookInput.Y * MouseSensitivityPitch;

        AddActorLocalRotation(FRotator(0.f, YawDelta, 0.f));

        if (SpringArmComp)
        {
            FRotator ArmRot = SpringArmComp->GetRelativeRotation();
            const float CurPitch = FRotator::NormalizeAxis(ArmRot.Pitch);

            ArmRot.Pitch = FMath::Clamp(CurPitch + PitchDelta, PitchMin, PitchMax);
            ArmRot.Roll  = 0.f;

            SpringArmComp->SetRelativeRotation(ArmRot);
        }

        // (선택) Look 입력은 누적 과해지면 소비하는 방식이 안정적
        CachedLookInput = FVector2D::ZeroVector;
    }
}

🧪 4) “왜 안 움직이지?” 디버깅 체크리스트 (실전)

케이스 A: IMC 로그는 찍히는데 입력이 안 들어옴

[Pawn] SetupPlayerInputComponent CALLED가 안 찍힘
→ Possess가 안 됐거나 입력 초기화가 안 됨

확인 포인트

GameMode 기본 Pawn

BP에서 Auto Possess Player

실제로 Controller가 Pawn을 Possess 하는지

케이스 B: SetupInput 로그는 찍히는데 이동이 안 됨

Tick이 꺼졌는지

CachedMoveInput이 0인지

AddActorLocalOffset(..., true)에서 Sweep에 막혔는지(충돌 프로필/채널)

추천 로그 세트 (원인 확정용)
UE_LOG(LogTemp, Warning, TEXT("[Tick] Move=(%.2f,%.2f) Look=(%.2f,%.2f)"),
CachedMoveInput.X, CachedMoveInput.Y,
CachedLookInput.X, CachedLookInput.Y);

🤖 5) E키로 드론 스폰/포제스 전환 (지상 ↔ 드론)

5-1. Pawn에서 Controller에게 토글 요청

void ABasePawn::Interact(const FInputActionValue& Value)
{
    UE_LOG(LogTemp, Warning, TEXT("[Pawn] Interact called"));

    if (AP3DPlayerController* PC = Cast<AP3DPlayerController>(GetController()))
    {
        PC->ToggleDrone();
    }
}

5-2. Controller에서 Spawn + Possess + IMC 전환

void AP3DPlayerController::ToggleDrone()
{
    UE_LOG(LogTemp, Warning, TEXT("[PC] ToggleDrone called. CurrentPawn=%s"),
        *GetNameSafe(GetPawn()));

    if (!DronePawnClass)
    {
        UE_LOG(LogTemp, Error, TEXT("[PC] DronePawnClass is NULL"));
        return;
    }

    APawn* CurrentPawn = GetPawn();
    if (!CurrentPawn) return;

    // 지상 -> 드론
    if (!CurrentPawn->IsA(ADronePawn::StaticClass()))
    {
        CachedPlayerPawn = CurrentPawn;

        if (!CachedDronePawn)
        {
            CachedDronePawn = SpawnDroneNear(CachedPlayerPawn);
        }

        if (CachedDronePawn)
        {
            Possess(CachedDronePawn);
            ApplyIMC(DroneInputMappingContext);
        }
        return;
    }

    // 드론 -> 지상
    if (CachedPlayerPawn)
    {
        Possess(CachedPlayerPawn);
        ApplyIMC(PawnInputMappingContext);
    }
}

✅ 실제로 막혔던 원인(로그로 확정)

DronePawnClass = NULL

해결: BP_P3DPlayerController에서 DronePawnClass에 BP_DronePawn 지정

🚁 6) 드론 UpDown 입력이 “점프처럼 1회만” 되는 문제

6-1. 증상

Space를 길게 눌러도 연속 상승 X (1회 튐)

Shift도 1회 하강 후 재입력 무반응

6-2. 원인 패턴

IA_UpDown에 Pressed 트리거 사용 → 한 번만 들어옴

Completed에서 0 리셋이 안 됨 → 캐시 값 꼬임

6-3. 정답 바인딩(Triggered + Completed)

EnhancedInput->BindAction(PC->UpDownAction, ETriggerEvent::Triggered, this, &ADronePawn::UpDown);
EnhancedInput->BindAction(PC->UpDownAction, ETriggerEvent::Completed, this, &ADronePawn::UpDown);

void ADronePawn::UpDown(const FInputActionValue& Value)
{
    CachedUpDownInput = Value.Get<float>(); // Space=+1, Shift=-1, Released=0
    UE_LOG(LogTemp, Warning, TEXT("UpDownInput=%.3f"), CachedUpDownInput);
}

🌍 7) 인공 중력(접지) – Z 이동 중복 적용 버그 & 설계 결론

7-1. 치명적 버그: “Z가 한 프레임에 2번 움직임”

TickGravity에서 AddActorWorldOffset(0,0,DeltaZ)

일반 이동에서도 Z 포함된 AddActorLocalOffset(...)

결과: 낙하/상승이 과해지고 접지 판정이 흔들림(떨림, 튕김, 고착)

7-2. 설계 결론(정답)

✅ 수평은 로컬 / 수직은 월드로 분리
→ 드론이 기울어도 월드 -Z로 낙하 유지됨

// 수평 이동(로컬: 전진/좌우)
const FVector LocalMove(CachedMoveInput.Y, CachedMoveInput.X, 0.f);
AddActorLocalOffset(LocalMove * CurSpeed * DeltaTime, true);

// 수직 이동(월드: 중력/추진)
AddActorWorldOffset(FVector(0,0,DeltaZ), true);

🎚️ 8) 에어컨트롤(공중 이동 제한) 적용

공중에서 방향은 유지

제어력(이동량)만 줄이기

float ControlMul = 1.f;
if (bEnableGravity && !bGrounded)
{
    ControlMul = FMath::Clamp(AirControlMultiplier, 0.f, 1.f); // 0.35~0.45
}
const float CurSpeed = NormalSpeed * ControlMul;

🚀 9) 추진(UpDown)을 “가속/감속 느낌”으로 만들기

핵심 아이디어

입력이 있을 때 VerticalVelocity 누적 가속

입력이 없을 때 드래그로 0에 수렴

중력은 공중에서만 적용(접지 떨림 방지)

// 추진 가속
VerticalVelocity += CachedUpDownInput * ThrustAccel * DeltaTime;

// 입력 없으면 감속(드래그)
if (FMath::IsNearlyZero(CachedUpDownInput))
{
    VerticalVelocity = FMath::FInterpTo(VerticalVelocity, 0.f, DeltaTime, ThrustDrag);
}

// 중력 적용(예: -980)
if (!bGrounded)
{
    VerticalVelocity += GravityAccel * DeltaTime;
}

// 속도 클램프
VerticalVelocity = FMath::Clamp(VerticalVelocity, -MaxFallSpeed, MaxRiseSpeed);

// 최종 Z 이동
const float DeltaZ = VerticalVelocity * DeltaTime;
AddActorWorldOffset(FVector(0,0,DeltaZ), true);

🧲 10) 접지 판정(Sweep) + 떨림 방지(스냅/스틱/코요테)

10-1. Sphere Sweep 기반 접지 Probe

bool ADronePawn::ProbeGround(FHitResult& OutHit) const
{
    const FVector Start = GetActorLocation();
    const float SphereR = SphereComp ? SphereComp->GetScaledSphereRadius() : 45.f;

    const float TraceLen = SphereR + GroundProbeDistance;
    const FVector End = Start + FVector(0,0,-TraceLen);

    FCollisionQueryParams Params(SCENE_QUERY_STAT(DroneGroundProbe), false);
    Params.AddIgnoredActor(this);

    const float SweepR = FMath::Max(1.f, SphereR - 2.f);
    const FCollisionShape Shape = FCollisionShape::MakeSphere(SweepR);

    return GetWorld()->SweepSingleByChannel(
        OutHit, Start, End, FQuat::Identity,
        ECC_Visibility, Shape, Params
    );
}

10-2. 코요테 타임

땅에서 막 떨어진 순간에도 짧게 “접지” 취급
→ 조작감 급상승

bGrounded = bHitGround || (TimeSinceGrounded <= CoyoteTime);

10-3. 스냅(바닥 가까우면 붙이기)

“바닥 바로 위에서 떠 있는 느낌” + 떨림 줄임

const float Gap = GroundHit.Distance - SphereR;

if (Gap >= 0.f && Gap <= GroundSnapMax && DeltaZ < 0.f)
{
    const float SnapDown = FMath::Max(0.f, Gap - GroundProbeDistance);
    DeltaZ = -SnapDown;
    VerticalVelocity = 0.f;
}

10-4. 스틱(접지 중 아래로 살짝 눌러 안정화)

단, 상승 입력(+)일 땐 스틱 금지(상승을 눌러막는 문제)

if (bGrounded)
{
    if (CachedUpDownInput <= 0.f)
    {
        VerticalVelocity = FMath::Min(VerticalVelocity, -GroundStickForce * DeltaTime);
    }
}

🧯 11) “텔레포트 복귀 후 Space만 안 됨” 문제 해결

현상

복귀 후 WASD는 정상

UpDown만 막힘(상승 불가)

결론(로그로 확정)

입력은 들어오는데 수직만 막히면

접지 고착 / 바닥 파묻힘 / 충돌 상태 꼬임 / Sweep이 계속 Block
가능성이 큼

해결: SafeTeleport + 상태 리셋 + Overlap 갱신

void ADronePawn::ResetFlightStateAfterTeleport()
{
    CachedMoveInput   = FVector2D::ZeroVector;
    CachedUpDownInput = 0.f;
    CachedLookInput   = FVector2D::ZeroVector;
    CachedRollInput   = 0.f;

    VerticalVelocity = 0.f;
    bGrounded = false;
    TimeSinceGrounded = 999.f;

    if (SphereComp)
    {
        SphereComp->UpdateOverlaps();
        SphereComp->WakeAllRigidBodies();
    }
}

void ADronePawn::SafeTeleportTo(const FVector& TargetLoc, const FRotator& TargetRot)
{
    const float SphereR = SphereComp ? SphereComp->GetScaledSphereRadius() : 45.f;
    const float Lift = SphereR + 10.f;

    SetActorEnableCollision(false);

    SetActorLocationAndRotation(
        TargetLoc + FVector(0,0,Lift),
        TargetRot,
        false, nullptr,
        ETeleportType::TeleportPhysics
    );

    SetActorEnableCollision(true);

    ResetFlightStateAfterTeleport();
}

🔄 12) ReturnToPlayer(E키) 안 되는 문제 (입력 충돌 / ValueType)

대표 원인 1) 키 매핑 충돌

E키가 RollAction(Q/E) 같은 다른 액션에 잡혀있어
Return 액션이 들어오지 않음

대표 원인 2) ValueType 불일치

ReturnToPlayerAction ValueType이 Bool이 아닌데 Get()만 함

방탄 처리 (타입 상관없이 Press 판정)

void ADronePawn::ReturnToPlayer(const FInputActionValue& Value)
{
    bool bPressed = false;

    if (Value.GetValueType() == EInputActionValueType::Boolean)
        bPressed = Value.Get<bool>();
    else
        bPressed = Value.Get<float>() > 0.5f;

    UE_LOG(LogTemp, Warning, TEXT("Return Pressed=%d Type=%d"),
        bPressed ? 1 : 0, (int32)Value.GetValueType());

    if (!bPressed) return;

    if (AP3DPlayerController* PC = Cast<AP3DPlayerController>(GetController()))
    {
        PC->ReturnToPlayer();
    }
}

🎭 13) Pawn 기반 애니메이션(Idle/Walk/Interact) 토글 디버깅

13-1. 왜 Speed가 0인가?

CharacterMovement를 안 쓰면 Velocity가 의미 없음

AnimBP가 MovementComponent 기반 Speed를 쓰면 항상 0

13-2. 위치 변화량 기반 Speed 계산

const float SafeDT = FMath::Max(DeltaTime, KINDA_SMALL_NUMBER);

const FVector NewLoc = GetActorLocation();
const FVector Delta  = NewLoc - PrevLocation;

CurrentSpeed2D = FVector(Delta.X, Delta.Y, 0.f).Size() / SafeDT;
PrevLocation = NewLoc;

깜빡임 방지(히스테리시스)

constexpr float StartMoveSpeed = 20.f;
constexpr float StopMoveSpeed  = 10.f;

if (!bIsMoving) bIsMoving = (CurrentSpeed2D > StartMoveSpeed);
else            bIsMoving = (CurrentSpeed2D > StopMoveSpeed);

13-3. Interact 토글 (입력은 순간, 상태는 유지)

입력은 Started(Pressed)

상태는 bool로 유지

Interact 중엔 이동/회전 차단

void ABasePawn::Interact(const FInputActionValue& Value)
{
    if (!Value.Get<bool>()) return;

    bIsInteracting = !bIsInteracting;

    UE_LOG(LogTemp, Warning,
        TEXT("[BasePawn] Interact TOGGLE -> %s"),
        bIsInteracting ? TEXT("ON") : TEXT("OFF"));

    if (bIsInteracting)
    {
        CachedMoveInput = FVector2D::ZeroVector;
        CachedLookInput = FVector2D::ZeroVector;
    }
}

const bool bCanControl = IsLocallyControlled() && !bIsInteracting;

if (!bCanControl)
{
    CachedMoveInput = FVector2D::ZeroVector;
    CachedLookInput = FVector2D::ZeroVector;
}

13-4. PlayRate가 “안 먹는 것처럼 보이던” 진짜 이유

애니 자체가 느린 게 아니라

Interact 상태가 1프레임 유지되고 바로 빠져나가던 구조였음

✅ 결론

“토글/유지형 연출”은 AnimNotify보다
bool 유지 + 상태 전이 설계가 훨씬 안정적

✍️ 느낀 점(회고)

Enhanced Input은 “세팅”보다 끊긴 지점 찾기(로그)가 전부였다.

애니메이션 문제처럼 보여도 실제로는 상태 유지/입력 이벤트 선택 문제가 더 많았다.

드론 중력은 “월드 수직”을 만족하려면 로컬/월드 축 분리가 가장 깔끔한 해법이었다.

텔레포트 이후 수직 먹통은 입력이 아니라 충돌/접지 상태 리셋 문제였고,
Lift + UpdateOverlaps()가 결정타였다.

profile
개발합시다!

0개의 댓글