캐릭터 전환 시스템
원신 스타일 파티 시스템과 캐릭터 스왑 구현
파티 시스템 아키텍처
캐릭터 관리를 위한 파티 컴포넌트 설계
// 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);
};
캐릭터 전환 구현
부드러운 캐릭터 스왑 트랜지션
// 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;
}
전환 이펙트 시스템
시각적 피드백과 애니메이션
// 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);
}
}
}
전환 중 상태 유지와 카메라 블렌딩
GAS 상태 이관과 부드러운 카메라 전환
캐릭터 전환 시 가장 중요한 두 가지 문제는 GAS 상태(버프, 쿨다운 등)의 올바른 이관과 카메라 블렌딩입니다. 상태가 유실되면 전투 중 전환이 의미 없어지고, 카메라가 끊기면 몰입이 깨집니다.
// 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초 정도로 짧게 설정하여 전투의 긴장감을 유지하세요.
APlayerController::Possess()를 호출하면 기존 Pawn의 UnPossessed()가 먼저 호출됩니다. 이 시점에 AbilitySystemComponent가 정리될 수 있으므로, Possess 호출 전에 GAS 상태를 저장해야 합니다. 순서가 바뀌면 쿨다운이나 버프 정보가 유실됩니다.
핵심 요약
- PartyComponent로 4인 파티 시스템 관리
- FPartyMemberInfo로 캐릭터 상태 저장/복원
- 쿨다운 시스템으로 스왑 빈도 제어
- 전환 이펙트로 시각적 피드백 제공
- SetViewTargetWithBlend로 부드러운 카메라 블렌딩
- FSavedAbilityState로 GAS 상태(버프, 쿨다운, 태그)를 전환 간 보존
도전 과제
배운 내용을 직접 실습해보세요
RPG 파티 시스템에서 최대 4명의 캐릭터를 관리하는 UPartySubsystem을 구현하세요. Possess/UnPossess를 통한 제어 캐릭터 전환 기능을 BlueprintCallable로 노출하세요.
캐릭터 전환 시 카메라 블렌딩(SetViewTargetWithBlend), UI 전환 애니메이션, 입력 잠금/해제를 구현하세요. GAS의 AbilitySystemComponent도 전환 시 올바르게 이전되도록 처리하세요.
플레이어가 제어하지 않는 파티원을 AI로 자동 전투하게 만드세요. BehaviorTree로 AI 전투 로직을 구현하고, 플레이어 전환 시 AI<->Player 제어를 매끄럽게 전환하세요.