(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-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-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-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;
}
}
케이스 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-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-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-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);
공중에서 방향은 유지
제어력(이동량)만 줄이기
float ControlMul = 1.f;
if (bEnableGravity && !bGrounded)
{
ControlMul = FMath::Clamp(AirControlMultiplier, 0.f, 1.f); // 0.35~0.45
}
const float CurSpeed = NormalSpeed * ControlMul;
핵심 아이디어
입력이 있을 때 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-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);
}
}
현상
복귀 후 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();
}
대표 원인 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-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()가 결정타였다.