14 분 소요

입력과 상호작용의 구조화에 대하여 알아보자

김하연 튜터님의 Notion 자료를 바탕으로 강의를 들으며
수정 및 재작성한 블로깅용 글

1. 전통 Input System의 문제와 패러다임 전환 🫤

입력 : 의외로 파고들면 생각할 것이 많은 부분

  • 같은 입력이라도 상황에 따라 다르게 처리함
    (핸드폰의 버튼부터, 모바일 게임의 터치 시스템 등등)

기존 입력 시스템의 문제점 (UE4)

void ACharacter::SetupPlayerInputComponent(UInputComponent* Input)
{
    Input->BindAction("Interact", IE_Pressed, this, &ACharacter::OnInteract);
}

void ACharacter::OnInteract()
{
    if (bIsInCleaningMode)
    {
        if (bNearCarpet)
            CleanCarpet();
        else if (bNearWindow)
            CleanWindow();
    }
    else if (bIsInCombatMode)
    {
        if (bHasWeapon)
            AttackWithWeapon();
        else
            Punch();
    }
    else if (bNearVehicle)
    {
        if (bInVehicle)
            ExitVehicle();
        else
            EnterVehicle();
    }
    // ... 계속 이어짐
}

문제점

  • 상황에 따른 다른 동작 불가능
  • 확장성 부족
  • 코드 재사용성 낮음

키와 행동이 1:1로 강하게 결합됨
-> 상황에 따라 행동의 정의를 다르게 바꿔주어야 함

  • E키에 문열기를 달았다면
    E키를 또 사용하려면 if로 분기를 사용

Enhanced Input System의 해결책

void ACharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    UEnhancedInputComponent* Input = Cast<UEnhancedInputComponent>(PlayerInputComponent);
    
    // "상호작용하고 싶다"는 의도만 바인딩
    Input->BindAction(InteractAction, ETriggerEvent::Triggered, this, &ACharacter::OnInteract);
}

void ACharacter::OnInteract()
{
    // if문이 없음ㅇㅋ
    // 현재 상황(Context)이 자동으로 의미를 결정
}

구조의 차이

전통 방식
[E키] → [OnInteract 함수] → [내부에서 if문으로 분기] → [행동]

Enhanced 방식
[E키] → [InteractAction (의도)] → [Context가 해석] → [행동]
  • Context를 통하여 분기문을 지움
  • 입력과 행동을 분리 시켜준다

2. Enhanced Input 아키텍처 📐

2-1. InputAction의 정체 - 데이터와 타입

🔹 InputAction은 단순한 데이터 에셋

  • 클래스: UInputAction → UDataAsset 상속
  • 역할: “입력의 타입과 의미”를 정의하는 설정 파일

그냥 데이터 에셋!
게임에서 사용할 입력 설정 정보를 담아놓은 것

enum class EInputActionValueType : uint8
{
    Boolean,   // 참/거짓
    Axis1D,    // 1차원 축
    Axis2D,    // 2차원 벡터
    Axis3D     // 3차원 벡터
};
타입 데이터 예시 사용 예
Boolean true / false 점프, 상호작용, 공격
Axis1D -1.0 ~ +1.0 앞뒤 이동(W/S), 좌우 회전
Axis2D (X, Y) 평면 이동 (WASD)
Axis3D (X, Y, Z) 3D 회전, 비행, 자유 이동
  • 엔진이 입력에 대한 수치 값을 제공

🔹 Axis2D 예시 (Move Action)

void AMyCharacter::Move(const FInputActionValue& Value)
{
    FVector2D Movement = Value.Get<FVector2D>();
    AddMovementInput(GetActorForwardVector(), Movement.Y);
    AddMovementInput(GetActorRightVector(), Movement.X);
}
  • W, D 동시 입력 → (0,1)+(1,0)=(1,1) → 대각선 이동
  • 길이 √2 → 정규화 필요 (엔진 옵션으로 자동 처리 가능)

🔹 Axis1D로 분리하는 이유

  • 카메라 회전은 X/Y 축 민감도를 다르게 조정해야 함
  • 따라서 LookX, LookY를 각각 Axis1D로 정의
IA_LookX → Axis1D  (마우스 X축)
IA_LookY → Axis1D  (마우스 Y축)

2-2. 입력 처리 파이프라인 개요

입력은 매 프레임 아래 과정을 거침.

1단계: [키 입력] 
   물리적으로 E키를 누름
   Raw Input Value = 1.0

2단계: [Modifier 적용]
   입력값을 변환
   예: Scale ×2.0 → 2.0

3단계: [Modified Value]
   변환된 최종 입력값 = 2.0

4단계: [Trigger 평가]
   이 입력이 조건을 만족하는가?
   예: 0.5초 이상 눌렀나?

5단계: [조건 만족 시]
   → InputAction 이벤트 발생
   → 바인딩된 함수 호출
  • 초당 60프레임이면 60번 처리됨

  • 등록한 조건을 만족해야 inputAction에 바인딩한 이벤트 호출!

2-3. Modifier - 입력값 변환

이름 기능 예시
Negate 부호 반전 W(+1), S(+1) → Negate → -1
Scale 민감도 조절 1.0 × 0.5 = 0.5
Dead Zone 잡음 제거 스틱의 미세한 흔들림 무시
Swizzle 축 재배열 (X,Y,Z) → (Y,X,Z)

🔹 예시 – Scale Modifier

// 마우스 민감도 조절
// UserSetting.Sensitivity = 0.5
Mapping.Modifiers.Add(NewObject<UInputModifier_Scale>());
Modifier->Scale = FVector(0.5f);

2-4. Trigger - 조건 평가

입력 이벤트가 “언제” 발동되는지를 정의

Trigger 조건 예시
Down 키가 눌렸는가 기본 클릭
Released 키를 뗐는가 활쏘기
Hold N초 유지 차지 공격
HoldAndRelease N초 유지 후 뗐는가 완충 후 발사
Tap N초 이내 눌렀다 뗐는가 더블탭 회피
Pulse 주기적으로 트리거 자동 연사

🔹 예시 – Hold Trigger (차지 공격)

BindAction(AttackAction, ETriggerEvent::Started, this, &AChar::LightAttack);
BindAction(AttackAction, ETriggerEvent::Triggered, this, &AChar::HeavyAttack);
  • Started: 키 눌린 즉시 (약공격)
  • Triggered: 0.5초 이상 눌렀을 때 (강공격)

2-5. InputAction의 생명 주기 - 엔진 내부 처리

// UEnhancedPlayerInput.cpp (단순화 버전)
void UEnhancedPlayerInput::ProcessInputStack(float DeltaTime)
{
    // 1️⃣ 활성 IMC 수집
    for (auto& IMC : ActiveContexts)
    {
        for (auto& Mapping : IMC->Mappings)
        {
            if (IsKeyPressed(Mapping.Key))
            {
                // 2️⃣ Modifier 적용
                FInputActionValue Value = Mapping.Key.GetRawValue();
                for (auto& Mod : Mapping.Modifiers)
                    Value = Mod->ModifyRaw(Value);

                // 3️⃣ 같은 Action 값 누적
                ActionValues[Mapping.Action].Accumulate(Value);
            }
        }
    }

    // 4️⃣ Trigger 평가 및 5️⃣ 이벤트 호출
    for (auto& Pair : ActionValues)
    {
        auto Action = Pair.Key;
        auto Value  = Pair.Value;
        auto Event  = EvaluateTriggers(Action, Value, DeltaTime);

        if (Event != ETriggerEvent::None)
            BroadcastInputAction(Action, Event, Value);
    }
}
  • ActiveContexts는 우선순위에 따라 정렬이 됨
    (UI Context 와 일반 GameContext의 우선순위가 다르면 높은 쪽이 처리)

  • 입력값을 누적시킨 후
    마지막에 조건에 만족하는 Input Action들에 대하여 BroadCast

🔹 단계 요약

단계 역할
1 ActiveContexts(IMC) 순회, 매핑 수집
2 Modifier 적용 (값 변환)
3 같은 Action 값 누적 (합산)
4 Trigger 조건 평가
5 이벤트 브로드캐스트 (바인딩 함수 호출)

3. Context 시스템의 실체 - Subsystem과 Stack 🕵🏻

  • 활성화된 IMC의 관리자는 ULocalPlayerSubsystem

3-1. Subsystem이란?

🔹 언리얼의 Subsystem 계층

Subsystem은 “특정 범위의 생명주기를 가지는 관리 객체”.

Subsystem 종류 생명 범위 예시
UGameInstanceSubsystem 게임 전체 세이브 데이터 관리
UWorldSubsystem 월드 단위 AI 네비게이션, 날씨
ULocalPlayerSubsystem 플레이어 단위 ✅ Enhanced Input 관리
  • Player 생성 시, 생성
    파괴 시 파괴

  • 레벨 변화에 따라서도 유지

🔹 Enhanced Input Subsystem 구조

UGameInstance
 └─ ULocalPlayer (플레이어 1)
      └─ UEnhancedInputLocalPlayerSubsystem
           └─ UEnhancedPlayerInput
 └─ ULocalPlayer (플레이어 2)
      └─ UEnhancedInputLocalPlayerSubsystem
           └─ UEnhancedPlayerInput

  • 플레이어마다 독립적인 입력 관리 시스템
  • LocalPlayer 기준이라, Possess가 바뀌어도 유지됨
    → (Spectator로 전환해도 입력 컨텍스트 유지 가능)

🔹 Subsystem 접근 경로

void UContextManagerComponent::InitializeSubsystem()
{
    AActor* Owner = GetOwner();  // 보통 캐릭터
    APlayerController* PC = Cast<APlayerController>(Owner->GetInstigatorController());
    ULocalPlayer* LocalPlayer = PC->GetLocalPlayer();

    CachedSubsystem =
        ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(LocalPlayer);
}

경로 요약:

Actor → PlayerController → LocalPlayer → Subsystem

게임 시작 직후나 AI 캐릭터는 PlayerController가 nullptr일 수 있음.

항상 null 체크 필수.

3-2. Priority System - 우선순위의 실제

🔹 내부 구조 (단순화)

class UEnhancedInputLocalPlayerSubsystem : public ULocalPlayerSubsystem
{
    TArray<FInputMappingContextAndPriority> AppliedInputContexts;

    void AddMappingContext(UInputMappingContext* IMC, int32 Priority);
    void RemoveMappingContext(UInputMappingContext* IMC);
};

struct FInputMappingContextAndPriority
{
    UInputMappingContext* MappingContext;
    int32 Priority;

    bool operator<(const FInputMappingContextAndPriority& Other) const
    {
        return Priority > Other.Priority; // 높은 Priority가 먼저
    }
};
  • AppliedInputContexts
    활성화된 IMC 관리
  • IMC를 관리하는 구조체를 통하여
    우선순위에 따라 IMC를 정렬
    (TArray 이지만, 값이 추가되거나 할때, 재정렬 하는 방식)

🔹 AddMappingContext 동작 흐름

void AddMappingContext(UInputMappingContext* IMC, int32 Priority)
{
    // 이미 등록되어 있으면 Priority 업데이트
    for (auto& Ctx : AppliedInputContexts)
        if (Ctx.MappingContext == IMC)
        {
            Ctx.Priority = Priority;
            AppliedInputContexts.Sort();
            return;
        }

    // 새로 추가
    AppliedInputContexts.Add({IMC, Priority});
    AppliedInputContexts.Sort();
}

  • IMC들은 항상 Priority 순서로 정렬됨.
  • 높은 Priority일수록 먼저 처리.

🔹 처리 순서 예시

순서 IMC 이름 Priority ConsumeInput 결과
① IMC_UI 100 ✅ UI 입력만 처리, 게임 입력 차단
② IMC_Combat 50 ❌ UI에서 처리 안한 입력만 통과
③ IMC_Default 0 ❌ 기본 입력 처리
→ 위에서 아래로 순회
   → ConsumeInput = true면 중단

🔹 ConsumeInput 예시

상황 ConsumeInput 결과
UI 메뉴 ✅ UI만 반응, 게임 무시
인게임 HUD ❌ HUD도 반응, 게임도 반응
// 실제 동작
if (Mapping.bConsumeInput)
    break; // 아래 IMC로 전파 중단

🔹 실전 예시 – UI 열기/닫기

void OpenMenu()
{
    Subsystem->AddMappingContext(IMC_Menu, 100);
}

void CloseMenu()
{
    Subsystem->RemoveMappingContext(IMC_Menu);
}
상태 Subsystem 내 IMC
기본 IMC_Default(0)
메뉴 열림 IMC_Menu(100), IMC_Default(0)
메뉴 닫힘 IMC_Default(0)

E키 → “문 열기” → “UI 클릭” 으로 동적 전환.

3-3. ContextManager의 역할 - 의미론적 레이어

🔹 Subsystem의 한계

한계 설명
상황 개념 없음 IMC 객체만 알고 “청소 모드” 의미를 모름
Stack 구조 없음 Priority 기반, “최근 추가” 개념 없음
중복 방지 없음 같은 IMC 여러 번 추가 가능
타입 안정성 부족 문자열/객체 기반, enum 미지원
상황 추적 불가 “이전 컨텍스트” 정보 없음
  • SubSystem 자체는 그냥 Priority 기준으로 정렬하여 관리만 함
    (IMC에 뭐가 들었는지 관심 없음)

  • 별도의 매니저를 통하여 위의 단점을 보조함

🔹 우리가 추가하는 구조

USTRUCT()
struct FActiveContext
{
    EGameplayContext Context;              // enum 기반 이름
    UInputMappingContext* MappingContext;  // 실제 IMC
    float ActivationTime;                  // 활성화 시점
};

UCLASS()
class UContextManagerComponent : public UActorComponent
{
    TArray<FActiveContext> ContextStack;
    UEnhancedInputLocalPlayerSubsystem* CachedSubsystem;
    TMap<EGameplayContext, UInputMappingContext*> ContextMappings;
};

  • 자체적으로 ContextManageComponent를 구현?

🔹 PushContext() 구현

void UContextManagerComponent::PushContext(EGameplayContext NewContext)
{
    if (!CachedSubsystem) return;

    // 중복 방지
    for (auto& Ctx : ContextStack)
        if (Ctx.Context == NewContext) return;

    UInputMappingContext* IMC = *ContextMappings.Find(NewContext);
    int32 Priority = ContextStack.Num(); // Stack 크기 = Priority
    CachedSubsystem->AddMappingContext(IMC, Priority);

    ContextStack.Add({NewContext, IMC, GetWorld()->GetTimeSeconds()});
}

🔹 PopContext() 구현

void UContextManagerComponent::PopContext()
{
    if (ContextStack.Num() == 0) return;

    const FActiveContext& Top = ContextStack.Last();
    CachedSubsystem->RemoveMappingContext(Top.MappingContext);
    ContextStack.Pop();
}

LIFO 구조 – 마지막으로 들어간 Context가 먼저 제거됨.

🔹 GetCurrentContext()

EGameplayContext UContextManagerComponent::GetCurrentContext() const
{
    return ContextStack.Num() == 0 ?
        EGameplayContext::Default :
        ContextStack.Last().Context;
}

🧠 의미론적 레이어의 가치

[우리 레이어]
  EGameplayContext::WindowCleaning
  EGameplayContext::CombatMode
    ↓ (ContextMappings)
[엔진 레이어]
  IMC_WindowCleaning
  IMC_Combat
    ↓ (Subsystem)
[입력 처리]
  E키 → InteractAction → "유리 닦기"
  • 레이어를 자체적으로 하나 두어
    엔진과 보조하여 사용함으로서
    입력 처리를 더 효율적으로 구현 가능

  • 상황에 따른 다른 동작을 더 세부적으로 분류 가능
    (State Machine까지 구현하지 않을 수 있음)

3-4. Priority 전략 - 우리의 선택

🔹 문제: Priority 0 고정의 위험

CachedSubsystem->AddMappingContext(IMC, 0);
  • 모든 IMC가 같은 우선순위면 순서 예측 불가
  • 엔진 버전에 따라 동작이 달라질 수 있음

🔹 해결: Stack 크기를 Priority로 사용

int32 Priority = ContextStack.Num();
CachedSubsystem->AddMappingContext(IMC, Priority);
  • Priority를 Stack값으로 지정하여
    우선순위를 확실히 나누는 방식
Context Priority 의미
Default 0 기본
FloorCleaning 1 위에 쌓임
WindowCleaning 2 최우선

숫자가 클수록 최근에 추가된 Context.

항상 “가장 최신 상황이 최우선”.

🔹 Pop 후 자동 복귀

PopContext(); // 최상위 제거 → 아래 Context가 우선순위 승계

⚡ 실제 동작 예시

단계 Stack Subsystem 상태 결과
1 [Default(0)] IMC_Default(0) 문 열기
2 [Default, Floor(1)] IMC_Floor(1), IMC_Default(0) 카펫 청소
3 [Default, Window(1)] IMC_Window(1), IMC_Default(0) 유리 닦기

3-5. 전체 구조 요약

[플레이어 입력]
     ↓
[InputAction]  ← 값/타입
     ↓
[ContextManager]  ← 의미/상황 (우리 시스템)
     ↓
[Subsystem]  ← IMC 관리 (엔진 시스템)
     ↓
[Input Mapping Context]  ← 실제 키 해석
     ↓
[함수/행동 실행]
항목 역할
Subsystem IMC를 Priority 순서로 관리
ConsumeInput 입력 전파 제어
ContextManager enum 기반 의미 추가
Stack 구조 LIFO, 동적 전환 지원
Priority 전략 Stack 크기 = Priority
GetCurrentContext 현재 상황 조회 API

4. State Machine - 행동 허가증 🛸

  • ‘상태 확인’에 대한 처리

4-1. Context vs. State - 외적 상황과 내적 상태

구분 Context State
의미 외적 상황 (환경/모드) 내적 상태 (현재 동작)
질문 “어떤 환경인가?” “지금 무엇을 하고 있는가?”
변경 주체 플레이어의 선택 시스템의 전환
예시 전투 모드, 청소 모드, 차량 탑승 Idle, Cleaning, Attacking, Dead
지속성 명시적 전환까지 유지 조건 충족 시 자동 변경

같은 장소(Context)라도, 상태(State)에 따라 가능한 행동이 다르다.

Context : 플레이어를 둘러싼 ‘환경’
(플레이어의 선택에 따라 환경의 변화)

State : 플레이어의 ‘상태’
(시스템을 따르는 ‘게임 캐릭터’의 현재 상황)
(실제 있는 데이터의 상황으로 판단하는 상태)

🔹 코드 비교

❌ Context만 있을 때 (문제 발생)

void OnInteract()
{
    if (CurrentContext == FloorCleaning)
        CleanFloor();  // 계속 호출됨 → 애니메이션 중복
}

✅ State까지 고려한 경우

void OnInteract()
{
    if (CurrentContext == FloorCleaning && CurrentState == Idle)
    {
        CleanFloor();
        CurrentState = Cleaning;
    }
}

State가 “허가증” 역할을 함.

Idle → Cleaning 전환은 허용, Cleaning 중이면 거부.

4-2. Finite State Machine (FSM) 패턴

🔹구성요소

  1. State – 가능한 상태 집합
  2. Transition – 상태 간 이동 규칙
  3. Guard – 전환 허용 조건

🔹 1️⃣ State 정의

enum class ECleaningState : uint8
{
    Idle,
    Cleaning,
};

Enum 사용으로 의미 명확, 타입 안정성 확보.

🔹 2️⃣ Transition 규칙 (다이어그램)

   ┌──────────┐
   │   Idle   │◄──────────────┐
   └────┬─────┘               │
        │                     │
        │ StartCleaning()     │ InterruptCleaning()
        │ (E키 입력)          │ (이동/점프/시간 종료)
        ↓                     │
   ┌──────────┐               │
   │ Cleaning │───────────────┘
   └──────────┘

🔹 3️⃣ Guard (조건 함수)

bool CanStartCleaning() const { return CurrentState == Idle; }
bool CanMove() const          { return CurrentState != Cleaning; }
bool CanJump() const          { return CurrentState != Cleaning; }

State Machine 클래스 구조

class UCleaningStateMachine : public UActorComponent
{
    GENERATED_BODY()

private:
    ECleaningState CurrentState = ECleaningState::Idle;
    float StateStartTime = 0.f;
    float AnimationDuration = 0.f;

    UPROPERTY()
    class AEnhancedChallengeCharacter* OwnerCharacter;

public:
    // 상태 전환
    void StartCleaning(float Duration);
    void InterruptCleaning();

    // 조건 검사
    bool CanStartCleaning() const;
    bool CanMove() const;
    bool CanJump() const;

    // 조회
    ECleaningState GetCurrentState() const { return CurrentState; }

protected:
    virtual void BeginPlay() override;
    virtual void TickComponent(float DeltaTime, ...) override;

private:
    void SetState(ECleaningState NewState);
    void StopCurrentMontage();
};
void UCleaningStateMachine::BeginPlay()
{
    Super::BeginPlay();
    
    // 캐릭터 참조 캐싱
    OwnerCharacter = Cast<AEnhancedChallengeCharacter>(GetOwner());
    
    if (!OwnerCharacter)
    {
        UE_LOG(LogTemp, Error, TEXT("StateMachine: Owner is not EnhancedChallengeCharacter!"));
    }
}
void UCleaningStateMachine::StartCleaning(float Duration)
{
    // 1단계: Guard 체크
    if (!CanStartCleaning())
    {
        UE_LOG(LogTemp, Warning, TEXT("Cannot start cleaning: wrong state (%d)"), 
            (int32)CurrentState);
        return;
    }
    
    // 2단계: Exit Current State
    // Idle 상태에서는 특별히 할 게 없음
    // 다른 상태였다면 여기서 정리 작업
    
    // 3단계: Transition (상태 변경)
    ECleaningState OldState = CurrentState;
    CurrentState = ECleaningState::Cleaning;
    
    // 4단계: Enter New State (새 상태 초기화)
    StateStartTime = GetWorld()->GetTimeSeconds();
    AnimationDuration = Duration;
    
    // 5단계: Event (상태 변경 알림)
    UE_LOG(LogTemp, Log, TEXT("State changed: %d -> %d"), 
        (int32)OldState, (int32)CurrentState);
    
    // 델리게이트로 외부에 알림 (나중에 설명)
    OnStateChanged.Broadcast(OldState, CurrentState);
}
void UCleaningStateMachine::TickComponent(
    float DeltaTime, 
    ELevelTick TickType, 
    FActorComponentTickFunction* ThisTickFunction)
{
    Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
    
    // Update State: Cleaning 상태일 때만 체크
    if (CurrentState == ECleaningState::Cleaning)
    {
        // 경과 시간 계산
        float CurrentTime = GetWorld()->GetTimeSeconds();
        float ElapsedTime = CurrentTime - StateStartTime;
        
        // 애니메이션 시간이 다 됐나?
        if (ElapsedTime >= AnimationDuration)
        {
            // 자동 전환: Cleaning → Idle
            UE_LOG(LogTemp, Log, TEXT("Cleaning finished (%.2fs), returning to Idle"), 
                ElapsedTime);
            
            SetState(ECleaningState::Idle);
        }
    }
}
void UCleaningStateMachine::InterruptCleaning()
{
    // 현재 Cleaning 상태일 때만
    if (CurrentState == ECleaningState::Cleaning)
    {
        UE_LOG(LogTemp, Log, TEXT("Cleaning interrupted!"));
        
        // 애니메이션 정지
        StopCurrentMontage();
        
        // 상태 전환
        SetState(ECleaningState::Idle);
    }
}
void UCleaningStateMachine::SetState(ECleaningState NewState)
{
    // 같은 상태로의 중복 전환 방지
    if (CurrentState == NewState)
    {
        UE_LOG(LogTemp, Warning, TEXT("Already in state: %d"), (int32)NewState);
        return;
    }
    
    // 이전 상태 저장
    ECleaningState OldState = CurrentState;
    
    // Exit Old State
    switch (OldState)
    {
    case ECleaningState::Cleaning:
        // 청소 상태를 빠져나갈 때 정리
        StopCurrentMontage();
        break;
        
    case ECleaningState::Idle:
        // Idle은 정리할 게 없음
        break;
    }
    
    // Transition
    CurrentState = NewState;
    
    // Enter New State
    switch (NewState)
    {
    case ECleaningState::Idle:
        // Idle은 초기화할 게 없음
        break;
        
    case ECleaningState::Cleaning:
        // Cleaning은 StartCleaning에서 초기화함
        break;
    }
    
    // Event
    UE_LOG(LogTemp, Log, TEXT("State changed: %d -> %d"), 
        (int32)OldState, (int32)NewState);
    
    // 델리게이트 브로드캐스트
    OnStateChanged.Broadcast(OldState, NewState);
}

4-3. Animation과 State의 분리

  • Animation의 재생은 믿기 힘든 상태

  • State의 제어하에 존재해야
    논리적인 버그가 생기지 않음

⚠️ 잘못된 방식

PlayAnimMontage(CleaningMontage); // 애니메이션만 실행
  • 중단되거나 스킵 가능 → 신뢰도 낮음
  • 논리적 상태 불일치 발생

✅ 올바른 방식

void OnInteract()
{
    // 1. State 확인
    if (!StateMachine->CanStartCleaning())
    {
        return;  // 불가능하면 아무것도 안 함
    }
    
    // 2. 애니메이션 재생
    float Duration = PlayAnimMontage(CleaningMontage);
    
    // 3. State Machine에 알림 (여기가 핵심!)
    StateMachine->StartCleaning(Duration);
}

State가 논리의 중심,

Animation은 표현만 담당.

StopCurrentMontage()

void UCleaningStateMachine::StopCurrentMontage()
{
    // 캐릭터가 유효한지 확인
    if (!OwnerCharacter)
    {
        UE_LOG(LogTemp, Warning, TEXT("OwnerCharacter is null!"));
        return;
    }
    
    // AnimInstance 가져오기
    USkeletalMeshComponent* Mesh = OwnerCharacter->GetMesh();
    if (!Mesh)
    {
        return;
    }
    
    UAnimInstance* AnimInstance = Mesh->GetAnimInstance();
    if (!AnimInstance)
    {
        return;
    }
    
    // 몽타주가 재생 중인지 확인
    if (AnimInstance->IsAnyMontagePlaying())
    {
        // 0.25초 블렌드 타임으로 부드럽게 정지
        AnimInstance->Montage_Stop(0.25f);
        
        UE_LOG(LogTemp, Log, TEXT("Stopped montage with 0.25s blend"));
    }
}

0.25초 블렌드 타임으로 부드럽게 전환.

4-4. Single Source of Truth

모든 시스템은 StateMachine만 참조해야 한다.

  • 조건 확인을 StateMachine만 하면 되기에 일관성이 생김
// UI 시스템
void UpdateHUD()
{
    switch (StateMachine->GetCurrentState())
    {
    case Idle:
        HUD->Hide();
        break;
    case Cleaning:
        HUD->ShowMessage("Cleaning...");
        break;
    }
}

// 전투 시스템
void TakeDamage(float Damage)
{
    // 청소 중엔 피해 안 받음
    if (StateMachine->GetCurrentState() == Cleaning)
    {
        return;
    }
    
    Health -= Damage;
}

// 다른 입력
void OnAttack()
{
    // 청소 중엔 공격 불가
    if (StateMachine->GetCurrentState() != Idle)
    {
        return;
    }
    
    Attack();
}

StateMachine이 논리적 진실의 단일 원천(Single Source of Truth).

5. Enhanced Input System이 추구한 설계 원칙 🧠

5-1. 단일 책임 원칙 (SRP)

Single Responsibility Principle

→ 하나의 클래스는 하나의 책임만 가져야 한다.

🔹 우리 시스템의 구조

클래스 책임
UContextManagerComponent 입력 의미 해석 (IMC 관리)
UCleaningStateMachine 상태 전이와 행동 허가
AEnhancedChallengeCharacter 입력 위임 및 통합
ContextManagerComponent  → 입력 해석만 담당
CleaningStateMachine     → 상태 관리만 담당
EnhancedChallengeCharacter → 위임만 담당

❌ 나쁜 예 – 책임 혼합

void ACharacter::OnInteract()
{
    // 입력 처리
    if (CurrentMode == CleaningMode) { ... }

    // 상태 확인
    if (bIsBusy) { ... }

    // 애니메이션 / UI / 사운드 전부 여기서
    PlayMontage(...);
    UpdateHUD(...);
    PlaySound(...);
}

→ 유지보수 지옥. 테스트 불가능. 의존 관계 꼬임.

✅ 좋은 구조 – 분리된 책임

  • ContextManager: 현재 모드(외적 상황)만 판단
  • StateMachine: 지금 가능한가(내적 상태)만 판단
  • Character: 호출만 담당

각 컴포넌트를 독립적으로 테스트 가능

수정해도 다른 부분 영향 없음

재사용성 극대화

5-2. 개방-폐쇄 원칙 (OCP)

Open–Closed Principle

→ “확장에는 열려 있고, 수정에는 닫혀 있어야 한다.”

🔹 새 Context 추가 예시

enum class EGameplayContext : uint8
{
    Default,
    FloorCleaning,
    WindowCleaning,
    Fishing,      // ← 새 기능 추가
};

1️⃣ IMC_Fishing 에셋 생성

2️⃣ ContextMappings에 등록

3️⃣ 끝. 기존 코드는 그대로.

확장만 했지, 기존 코드는 수정하지 않았다.

🔹 새 State 추가 예시

enum class ECleaningState : uint8
{
    Idle,
    Cleaning,
    Resting,     // ← 추가
    Exhausted    // ← 추가
};

void StartResting()
{
    if (CurrentState == Idle || CurrentState == Exhausted)
        SetState(Resting);
}

  • Idle ↔ Cleaning 로직은 그대로
  • 새로운 전이(Transition)만 추가

5-3. 의존성 역전 원칙 (DIP)

Dependency Inversion Principle

→ 구체적 입력(E키, 게임패드 버튼)에 의존하지 않고,

추상적 의도(InputAction) 에 의존해야 한다.

❌ 나쁜 예

if (IsKeyPressed(EKeys::E))  // 키보드에 고정 의존
    CleanFloor();
  • 키보드에서만 작동
  • 게임패드나 VR 컨트롤러 추가 시 코드 수정 필요

✅ 좋은 예

void OnInteract(const FInputActionValue& Value)
{
    // 어떤 키인지 모름
    // 어떤 장치인지도 모름
    ExecuteInteraction();  // 추상화된 의도만 실행
}

InputAction = “상호작용하고 싶다”라는 추상 신호

→ 장치(IMC)만 바꾸면 그대로 동작

5-4. 확장 시나리오

시나리오 1: 멀티 도구 시스템

enum class EGameplayContext : uint8
{
    // 청소
    FloorCleaning,
    WindowCleaning,
    CarCleaning,

    // 전투
    Sword,
    Bow,
    Magic,

    // 이동 수단
    OnFoot,
    InCar,
    InBoat,
};

  • 각각 IMC를 따로 등록
  • PushContext()로 모드 전환 시 완전히 다른 조작 체계

시나리오 2: 복합 State Machine

class ACharacter
{
    UCleaningStateMachine* CleaningState;
    UCombatStateMachine* CombatState;
    UMovementStateMachine* MovementState;
};

void OnAttack()
{
    if (CombatState->CanAttack() &&
        CleaningState->IsIdle() &&
        MovementState->CanAct())
    {
        Attack();
    }
}

여러 FSM을 병렬로 운용해도 간섭 없이 동작.

시나리오 3: 데이터 주도 설계

USTRUCT()
struct FContextDefinition
{
    EGameplayContext ContextType;
    UInputMappingContext* IMC;
    TArray<UAnimMontage*> AvailableActions;
    float StaminaCost;
};

  • Context 정의를 Data Asset으로 관리
  • 디자이너가 밸런스 조정 가능
  • 코드 수정 없이 행동 세부 조정 가능

댓글남기기