PART 12 - 강의 4/8

캐릭터 전환 시스템

원신 스타일 파티 시스템과 캐릭터 스왑 구현

01

파티 시스템 아키텍처

캐릭터 관리를 위한 파티 컴포넌트 설계

C++
// PartyComponent.h - 파티 관리 컴포넌트 #pragma once #include "CoreMinimal.h" #include "Components/ActorComponent.h" #include "PartyComponent.generated.h" class APlayableCharacter; /** * 파티 멤버 정보 구조체 */ USTRUCT(BlueprintType) struct FPartyMemberInfo { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadWrite) TSubclassOf<APlayableCharacter> CharacterClass; UPROPERTY(EditAnywhere, BlueprintReadWrite) FName CharacterID; UPROPERTY(BlueprintReadWrite) float CurrentHealth = 100.0f; UPROPERTY(BlueprintReadWrite) float MaxHealth = 100.0f; UPROPERTY(BlueprintReadWrite) float CurrentEnergy = 0.0f; UPROPERTY(BlueprintReadWrite) float MaxEnergy = 100.0f; UPROPERTY(BlueprintReadWrite) float SkillCooldownRemaining = 0.0f; UPROPERTY(BlueprintReadWrite) bool bIsAlive = true; }; /** * 캐릭터 전환 이벤트 델리게이트 */ DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( FOnCharacterSwitched, APlayableCharacter*, OldCharacter, APlayableCharacter*, NewCharacter ); DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam( FOnPartyMemberUpdated, int32, SlotIndex ); /** * 파티 관리 컴포넌트 * PlayerController에 부착하여 사용 */ UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent)) class OPENWORLDRPG_API UPartyComponent : public UActorComponent { GENERATED_BODY() public: UPartyComponent(); // 최대 파티 크기 static constexpr int32 MAX_PARTY_SIZE = 4; // 캐릭터 전환 쿨다운 UPROPERTY(EditDefaultsOnly, Category = "Party") float SwitchCooldown = 1.0f; // 이벤트 UPROPERTY(BlueprintAssignable) FOnCharacterSwitched OnCharacterSwitched; UPROPERTY(BlueprintAssignable) FOnPartyMemberUpdated OnPartyMemberUpdated; // 파티 슬롯에 캐릭터 추가 UFUNCTION(BlueprintCallable, Category = "Party") bool AddCharacterToParty(TSubclassOf<APlayableCharacter> CharacterClass, int32 SlotIndex); // 캐릭터 전환 (슬롯 인덱스로) UFUNCTION(BlueprintCallable, Category = "Party") bool SwitchToCharacter(int32 SlotIndex); // 다음/이전 캐릭터로 전환 UFUNCTION(BlueprintCallable, Category = "Party") bool SwitchToNextCharacter(); UFUNCTION(BlueprintCallable, Category = "Party") bool SwitchToPreviousCharacter(); // 현재 활성 캐릭터 UFUNCTION(BlueprintPure, Category = "Party") APlayableCharacter* GetActiveCharacter() const; UFUNCTION(BlueprintPure, Category = "Party") int32 GetActiveSlotIndex() const { return ActiveSlotIndex; } // 파티 멤버 정보 UFUNCTION(BlueprintPure, Category = "Party") const FPartyMemberInfo& GetPartyMemberInfo(int32 SlotIndex) const; // 파티 멤버 상태 업데이트 UFUNCTION(BlueprintCallable, Category = "Party") void UpdatePartyMemberHealth(int32 SlotIndex, float CurrentHealth, float MaxHealth); protected: virtual void BeginPlay() override; virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; private: // 파티 멤버 정보 UPROPERTY() TArray<FPartyMemberInfo> PartyMembers; // 스폰된 캐릭터 참조 UPROPERTY() TArray<TObjectPtr<APlayableCharacter>> SpawnedCharacters; // 현재 활성 슬롯 int32 ActiveSlotIndex = 0; // 전환 쿨다운 타이머 float SwitchCooldownTimer = 0.0f; // 내부 전환 로직 void PerformCharacterSwitch(int32 NewSlotIndex); // 캐릭터 스폰 APlayableCharacter* SpawnCharacter(int32 SlotIndex); };
02

캐릭터 전환 구현

부드러운 캐릭터 스왑 트랜지션

C++
// PartyComponent.cpp - 핵심 전환 로직 #include "PartyComponent.h" #include "PlayableCharacter.h" #include "GameFramework/PlayerController.h" UPartyComponent::UPartyComponent() { PrimaryComponentTick.bCanEverTick = true; // 파티 슬롯 초기화 PartyMembers.SetNum(MAX_PARTY_SIZE); SpawnedCharacters.SetNum(MAX_PARTY_SIZE); } void UPartyComponent::BeginPlay() { Super::BeginPlay(); // 초기 활성 캐릭터 스폰 if (PartyMembers[0].CharacterClass) { SpawnCharacter(0); } } void UPartyComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); // 전환 쿨다운 처리 if (SwitchCooldownTimer > 0.0f) { SwitchCooldownTimer -= DeltaTime; } // 각 캐릭터의 스킬 쿨다운 업데이트 for (int32 i = 0; i < MAX_PARTY_SIZE; ++i) { if (PartyMembers[i].SkillCooldownRemaining > 0.0f) { PartyMembers[i].SkillCooldownRemaining -= DeltaTime; OnPartyMemberUpdated.Broadcast(i); } } } bool UPartyComponent::SwitchToCharacter(int32 SlotIndex) { // 유효성 검사 if (SlotIndex < 0 || SlotIndex >= MAX_PARTY_SIZE) { return false; } // 같은 캐릭터로 전환 시도 if (SlotIndex == ActiveSlotIndex) { return false; } // 쿨다운 체크 if (SwitchCooldownTimer > 0.0f) { return false; } // 해당 슬롯에 캐릭터가 있는지 if (!PartyMembers[SlotIndex].CharacterClass) { return false; } // 캐릭터가 살아있는지 if (!PartyMembers[SlotIndex].bIsAlive) { return false; } PerformCharacterSwitch(SlotIndex); return true; } void UPartyComponent::PerformCharacterSwitch(int32 NewSlotIndex) { APlayableCharacter* OldCharacter = GetActiveCharacter(); FVector SwitchLocation; FRotator SwitchRotation; if (OldCharacter) { // 현재 캐릭터 상태 저장 PartyMembers[ActiveSlotIndex].CurrentHealth = OldCharacter->GetHealth(); PartyMembers[ActiveSlotIndex].CurrentEnergy = OldCharacter->GetEnergy(); // 전환 위치/회전 저장 SwitchLocation = OldCharacter->GetActorLocation(); SwitchRotation = OldCharacter->GetActorRotation(); // 이전 캐릭터 퇴장 이펙트 OldCharacter->PlaySwitchOutEffect(); // 이전 캐릭터 비활성화 OldCharacter->SetActorHiddenInGame(true); OldCharacter->SetActorEnableCollision(false); OldCharacter->SetActorTickEnabled(false); } else { // 기본 위치 사용 if (APlayerController* PC = Cast<APlayerController>(GetOwner())) { PC->GetPlayerViewPoint(SwitchLocation, SwitchRotation); } } // 새 캐릭터 스폰 또는 활성화 APlayableCharacter* NewCharacter = SpawnedCharacters[NewSlotIndex]; if (!NewCharacter) { NewCharacter = SpawnCharacter(NewSlotIndex); } if (NewCharacter) { // 위치 설정 NewCharacter->SetActorLocationAndRotation(SwitchLocation, SwitchRotation); // 상태 복원 NewCharacter->SetHealth(PartyMembers[NewSlotIndex].CurrentHealth); NewCharacter->SetEnergy(PartyMembers[NewSlotIndex].CurrentEnergy); // 활성화 NewCharacter->SetActorHiddenInGame(false); NewCharacter->SetActorEnableCollision(true); NewCharacter->SetActorTickEnabled(true); // 등장 이펙트 NewCharacter->PlaySwitchInEffect(); // 컨트롤러 빙의 if (APlayerController* PC = Cast<APlayerController>(GetOwner())) { PC->Possess(NewCharacter); } } // 슬롯 인덱스 업데이트 ActiveSlotIndex = NewSlotIndex; // 쿨다운 시작 SwitchCooldownTimer = SwitchCooldown; // 이벤트 브로드캐스트 OnCharacterSwitched.Broadcast(OldCharacter, NewCharacter); } APlayableCharacter* UPartyComponent::SpawnCharacter(int32 SlotIndex) { if (!PartyMembers[SlotIndex].CharacterClass) { return nullptr; } UWorld* World = GetWorld(); if (!World) { return nullptr; } FActorSpawnParameters SpawnParams; SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn; APlayableCharacter* NewCharacter = World->SpawnActor<APlayableCharacter>( PartyMembers[SlotIndex].CharacterClass, FVector::ZeroVector, FRotator::ZeroRotator, SpawnParams ); if (NewCharacter) { SpawnedCharacters[SlotIndex] = NewCharacter; NewCharacter->SetPartySlotIndex(SlotIndex); // 초기 상태 설정 NewCharacter->SetHealth(PartyMembers[SlotIndex].CurrentHealth); NewCharacter->SetEnergy(PartyMembers[SlotIndex].CurrentEnergy); } return NewCharacter; } bool UPartyComponent::SwitchToNextCharacter() { int32 NextSlot = (ActiveSlotIndex + 1) % MAX_PARTY_SIZE; int32 StartSlot = NextSlot; do { if (PartyMembers[NextSlot].CharacterClass && PartyMembers[NextSlot].bIsAlive) { return SwitchToCharacter(NextSlot); } NextSlot = (NextSlot + 1) % MAX_PARTY_SIZE; } while (NextSlot != StartSlot); return false; }
03

전환 이펙트 시스템

시각적 피드백과 애니메이션

C++
// PlayableCharacter.h - 전환 이펙트 #pragma once #include "CoreMinimal.h" #include "GameFramework/Character.h" #include "PlayableCharacter.generated.h" UCLASS() class OPENWORLDRPG_API APlayableCharacter : public ACharacter { GENERATED_BODY() public: APlayableCharacter(); // 전환 이펙트 UFUNCTION(BlueprintCallable, Category = "Switch") void PlaySwitchInEffect(); UFUNCTION(BlueprintCallable, Category = "Switch") void PlaySwitchOutEffect(); // 상태 관리 UFUNCTION(BlueprintPure) float GetHealth() const { return CurrentHealth; } UFUNCTION(BlueprintCallable) void SetHealth(float NewHealth); UFUNCTION(BlueprintPure) float GetEnergy() const { return CurrentEnergy; } UFUNCTION(BlueprintCallable) void SetEnergy(float NewEnergy); // 파티 슬롯 void SetPartySlotIndex(int32 Index) { PartySlotIndex = Index; } int32 GetPartySlotIndex() const { return PartySlotIndex; } protected: // 스위치 이펙트 UPROPERTY(EditDefaultsOnly, Category = "Switch") TObjectPtr<UParticleSystem> SwitchInParticle; UPROPERTY(EditDefaultsOnly, Category = "Switch") TObjectPtr<UParticleSystem> SwitchOutParticle; UPROPERTY(EditDefaultsOnly, Category = "Switch") TObjectPtr<USoundBase> SwitchInSound; UPROPERTY(EditDefaultsOnly, Category = "Switch") TObjectPtr<USoundBase> SwitchOutSound; UPROPERTY(EditDefaultsOnly, Category = "Switch") TObjectPtr<UAnimMontage> SwitchInMontage; // 원소 타입 (캐릭터별 고유) UPROPERTY(EditDefaultsOnly, Category = "Character") TObjectPtr<UMaterialInterface> ElementalMaterial; private: UPROPERTY() float CurrentHealth = 100.0f; UPROPERTY() float CurrentEnergy = 0.0f; int32 PartySlotIndex = -1; }; // PlayableCharacter.cpp #include "PlayableCharacter.h" #include "Kismet/GameplayStatics.h" #include "Components/SkeletalMeshComponent.h" #include "Materials/MaterialInstanceDynamic.h" void APlayableCharacter::PlaySwitchInEffect() { // 파티클 이펙트 if (SwitchInParticle) { UGameplayStatics::SpawnEmitterAtLocation( GetWorld(), SwitchInParticle, GetActorLocation(), GetActorRotation() ); } // 사운드 if (SwitchInSound) { UGameplayStatics::PlaySoundAtLocation( this, SwitchInSound, GetActorLocation() ); } // 등장 애니메이션 if (SwitchInMontage) { PlayAnimMontage(SwitchInMontage); } // 머티리얼 페이드인 이펙트 StartMaterialFadeIn(); } void APlayableCharacter::PlaySwitchOutEffect() { // 퇴장 파티클 if (SwitchOutParticle) { UGameplayStatics::SpawnEmitterAtLocation( GetWorld(), SwitchOutParticle, GetActorLocation(), GetActorRotation() ); } // 퇴장 사운드 if (SwitchOutSound) { UGameplayStatics::PlaySoundAtLocation( this, SwitchOutSound, GetActorLocation() ); } } void APlayableCharacter::StartMaterialFadeIn() { // 다이나믹 머티리얼 인스턴스 생성 USkeletalMeshComponent* Mesh = GetMesh(); if (!Mesh) return; for (int32 i = 0; i < Mesh->GetNumMaterials(); ++i) { UMaterialInstanceDynamic* DynMat = Mesh->CreateDynamicMaterialInstance(i); if (DynMat) { // 페이드 애니메이션 시작 DynMat->SetScalarParameterValue(FName("FadeAmount"), 0.0f); // 타임라인으로 페이드인 (0 -> 1) AnimateMaterialFade(DynMat, 0.0f, 1.0f, 0.3f); } } }
04

전환 중 상태 유지와 카메라 블렌딩

GAS 상태 이관과 부드러운 카메라 전환

캐릭터 전환 시 가장 중요한 두 가지 문제는 GAS 상태(버프, 쿨다운 등)의 올바른 이관카메라 블렌딩입니다. 상태가 유실되면 전투 중 전환이 의미 없어지고, 카메라가 끊기면 몰입이 깨집니다.

C++
// CharacterSwitchManager.h - 상태 유지 & 카메라 블렌딩 #pragma once #include "CoreMinimal.h" #include "AbilitySystemComponent.h" #include "Camera/CameraComponent.h" #include "CharacterSwitchManager.generated.h" /** * 캐릭터 전환 시 GAS 상태 보존 데이터 */ USTRUCT() struct FSavedAbilityState { GENERATED_BODY() // 활성 GameplayEffect 스냅샷 TArray<FActiveGameplayEffectHandle> ActiveEffects; // 어빌리티 쿨다운 잔여 시간 TMap<FGameplayTag, float> CooldownRemaining; // 현재 적용 중인 GameplayTag FGameplayTagContainer ActiveTags; }; UCLASS() class OPENWORLDRPG_API UCharacterSwitchManager : public UActorComponent { GENERATED_BODY() public: // 전환 전: 현재 캐릭터의 GAS 상태 저장 UFUNCTION(BlueprintCallable, Category = "Switch") FSavedAbilityState SaveAbilityState( UAbilitySystemComponent* ASC) { FSavedAbilityState State; if (!ASC) return State; // 활성 GE 저장 (Duration/Infinite만) const FActiveGameplayEffectsContainer& ActiveGEs = ASC->GetActiveGameplayEffects(); for (const FActiveGameplayEffect& GE : &ActiveGEs) { if (GE.Spec.Def->DurationPolicy != EGameplayEffectDurationType::Instant) { State.ActiveEffects.Add(GE.Handle); } } // 쿨다운 잔여 시간 저장 FGameplayTagContainer CooldownTags; ASC->GetAllCooldownTags(CooldownTags); for (const FGameplayTag& Tag : CooldownTags) { float Remaining = 0.0f; float Duration = 0.0f; ASC->GetCooldownTimeRemainingAndDuration( Tag, Remaining, Duration); State.CooldownRemaining.Add(Tag, Remaining); } // 현재 활성 태그 저장 ASC->GetOwnedGameplayTags(State.ActiveTags); return State; } // 카메라 블렌딩 수행 UFUNCTION(BlueprintCallable, Category = "Switch") void BlendCameraToNewCharacter( APlayerController* PC, AActor* OldCharacter, AActor* NewCharacter, float BlendTime = 0.3f) { if (!PC || !NewCharacter) return; // ViewTarget 블렌딩으로 부드러운 전환 FViewTargetTransitionParams Params; Params.BlendTime = BlendTime; Params.BlendFunction = EViewTargetBlendFunction::VTBlend_EaseInOut; Params.BlendExp = 2.0f; // 락온 상태면 블렌드 타임을 짧게 if (bIsLockedOn) { Params.BlendTime = 0.15f; Params.bLockOutgoing = true; } PC->SetViewTargetWithBlend( NewCharacter, BlendTime, Params.BlendFunction, Params.BlendExp, Params.bLockOutgoing); } // 전환 중 입력 잠금 UFUNCTION(BlueprintCallable, Category = "Switch") void LockInputDuringTransition( APlayerController* PC, float Duration) { if (!PC) return; // 입력 비활성화 PC->SetIgnoreMoveInput(true); PC->SetIgnoreLookInput(true); // 타이머로 자동 해제 FTimerHandle TimerHandle; GetWorld()->GetTimerManager().SetTimer( TimerHandle, [PC]() { if (PC) { PC->SetIgnoreMoveInput(false); PC->SetIgnoreLookInput(false); } }, Duration, false); } private: bool bIsLockedOn = false; };
전환 시 상태 관리 체크리스트
  • HP/MP/기력 -- FPartyMemberInfo에 저장하고 전환 시 복원
  • 버프/디버프 -- GameplayEffect의 Duration이 남아있는 GE를 보존
  • 쿨다운 -- 비활성 상태에서도 쿨다운이 진행되도록 TickComponent에서 감소
  • 카메라 -- SetViewTargetWithBlend로 EaseInOut 블렌딩
  • 입력 -- 전환 중 0.3초간 입력 잠금으로 오입력 방지
락온 상태에서의 전환

타겟 락온 상태에서 캐릭터를 전환할 경우, 새 캐릭터도 동일한 타겟을 자동으로 락온하도록 LockOnComponent의 타겟 정보를 이관해야 합니다. 카메라 블렌드 시간도 0.15초 정도로 짧게 설정하여 전투의 긴장감을 유지하세요.

Possess 타이밍 주의

APlayerController::Possess()를 호출하면 기존 Pawn의 UnPossessed()가 먼저 호출됩니다. 이 시점에 AbilitySystemComponent가 정리될 수 있으므로, Possess 호출 전에 GAS 상태를 저장해야 합니다. 순서가 바뀌면 쿨다운이나 버프 정보가 유실됩니다.

SUMMARY

핵심 요약

  • PartyComponent로 4인 파티 시스템 관리
  • FPartyMemberInfo로 캐릭터 상태 저장/복원
  • 쿨다운 시스템으로 스왑 빈도 제어
  • 전환 이펙트로 시각적 피드백 제공
  • SetViewTargetWithBlend로 부드러운 카메라 블렌딩
  • FSavedAbilityState로 GAS 상태(버프, 쿨다운, 태그)를 전환 간 보존
PRACTICE

도전 과제

배운 내용을 직접 실습해보세요

실습 1: 다중 캐릭터 관리 시스템

RPG 파티 시스템에서 최대 4명의 캐릭터를 관리하는 UPartySubsystem을 구현하세요. Possess/UnPossess를 통한 제어 캐릭터 전환 기능을 BlueprintCallable로 노출하세요.

실습 2: 캐릭터 전환 트랜지션

캐릭터 전환 시 카메라 블렌딩(SetViewTargetWithBlend), UI 전환 애니메이션, 입력 잠금/해제를 구현하세요. GAS의 AbilitySystemComponent도 전환 시 올바르게 이전되도록 처리하세요.

심화 과제: AI 제어 파티원 시스템

플레이어가 제어하지 않는 파티원을 AI로 자동 전투하게 만드세요. BehaviorTree로 AI 전투 로직을 구현하고, 플레이어 전환 시 AI<->Player 제어를 매끄럽게 전환하세요.