이번엔 기존에 치료 기능으로만 존재하던 Medkit을 월드 아이템으로 변경하고, 새로운 소모품인 Speed Potion을 추가했다.
정리하면 이번 목표는 이렇다.
IA_Drop 으로 처리한다기존에는 ASPCollectibleItem만 상호작용 가능한 월드 아이템이었다.
하지만 Medkit과 Speed Potion도 똑같이 줍고 버려야 하므로, 공통 부모인 ASPPickupItem을 만들고 두 종류의 아이템이 이를 상속하게 했다.
UCLASS(Abstract, Blueprintable)
class SPACH4_API ASPPickupItem : public AActor, public ISPInteractable
{
GENERATED_BODY()
public:
virtual void Interact_Implementation(AActor* Interactor) override;
virtual void SetHighlight_Implementation(bool bEnabled) override;
virtual bool IsInteractable_Implementation() const override;
bool TryReserve(ASurvivorCharacter* Survivor);
void ReleaseReservation(ASurvivorCharacter* Survivor);
void SetStored(bool bNewStored, const FVector& WorldLocation);
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "SP|Item")
TObjectPtr<UStaticMeshComponent> Mesh;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "SP|Item")
TSoftObjectPtr<UTexture2D> Icon;
};
상호작용되면 아이템이 인벤토리를 직접 수정하지 않고, 생존자의 기존 줍기 흐름으로 진입한다.
void ASPPickupItem::Interact_Implementation(AActor* Interactor)
{
if (ASurvivorCharacter* Survivor = Cast<ASurvivorCharacter>(Interactor))
{
Survivor->BeginPickup(this);
}
}
수집품과 소모품은 별도의 인벤토리를 만들지 않고 같은 4칸을 사용하도록 했다.
슬롯은 자신이 어떤 종류의 아이템을 가지고 있는지와 수량을 함께 저장한다.
UENUM(BlueprintType)
enum class EInventorySlotContentType : uint8
{
Empty,
Collectible,
Consumable
};
USTRUCT(BlueprintType)
struct FInventorySlotEntry
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly)
EInventorySlotContentType ContentType = EInventorySlotContentType::Empty;
UPROPERTY(BlueprintReadOnly)
EConsumableItemType ConsumableType = EConsumableItemType::None;
UPROPERTY(BlueprintReadOnly)
int32 Quantity = 0;
UPROPERTY(BlueprintReadOnly)
TSoftObjectPtr<UTexture2D> Icon;
UPROPERTY()
TObjectPtr<ASPPickupItem> SourceItem;
};
여기서 한 슬롯은 하나의 아이템 종류를 표현한다.
동일한 소모품을 여러 개 가지고 있으면 새로운 슬롯을 차지하는 것이 아니라 해당 슬롯의 Quantity만 증가한다.
예를 들어 Medkit을 하나 가진 상태에서 Medkit 두 개를 더 주우면 다음과 같이 된다.
1번 슬롯: Medkit ×3
2번 슬롯: Empty
3번 슬롯: Empty
4번 슬롯: Empty
처음 문제가 되었던 부분은 같은 소모품을 주웠을 때 2번, 3번 슬롯에도 같은 아이콘이 복제되는 것이었다.
원하는 동작은 같은 종류가 이미 존재하면 그 슬롯의 숫자만 증가하는 것이다.
그래서 아이템을 저장하기 전에 같은 타입의 소모품 슬롯을 먼저 찾도록 했다.
if (const ASPConsumableItem* Consumable = Cast<ASPConsumableItem>(Item))
{
const EConsumableItemType ItemType = Consumable->GetConsumableType();
const int32 ExistingIndex = FindConsumableSlotIndex(ItemType);
if (ExistingIndex != INDEX_NONE)
{
FInventorySlotEntry& ExistingSlot = InventorySlots[ExistingIndex];
++ExistingSlot.Quantity;
Item->Destroy();
BroadcastInventoryChanged();
return true;
}
}
첫 번째 아이템의 월드 액터는 SourceItem으로 보관하고, 이후 같은 종류의 아이템은 수량에 흡수한 뒤 제거한다.
새로운 종류의 아이템일 때만 빈 슬롯을 찾는다.
const int32 EmptyIndex = FindFirstEmptySlotIndex();
if (EmptyIndex == INDEX_NONE)
return false;
FInventorySlotEntry& Slot = InventorySlots[EmptyIndex];
Slot.SourceItem = Item;
Slot.Icon = Item->GetIcon();
Slot.Quantity = 1;
Slot.ContentType = EInventorySlotContentType::Consumable;
Slot.ConsumableType = Consumable->GetConsumableType();
따라서 Medkit과 Speed Potion은 서로 다른 슬롯을 사용하지만, 같은 종류끼리는 각각 하나의 슬롯 안에서 수량으로 중첩된다.
HUD도 전체 보유량을 모든 슬롯에 전달하면 안 되고, 반드시 각 슬롯이 가진 수량을 그대로 사용해야 한다.
case EInventorySlotContentType::Consumable:
HUDSlot.ItemName =
SPInventoryText::GetConsumableDisplayName(Slot.ConsumableType);
HUDSlot.Quantity = Slot.Quantity;
break;
HUD에서는 수량이 1일 때 숫자를 숨기고, 2개 이상일 때만 표시하도록 했다.
const int32 Quantity =
bValidData ? InventoryData[Index].Quantity : 0;
CountText->SetText(
Quantity > 1
? FText::AsNumber(Quantity)
: FText::GetEmpty());
CountText->SetVisibility(
Quantity > 1
? ESlateVisibility::HitTestInvisible
: ESlateVisibility::Collapsed);
이제 같은 소모품을 주우면 다른 슬롯의 아이콘이 복제되지 않고, 기존 슬롯의 숫자만 2, 3처럼 증가한다.
멀티플레이에서 두 생존자가 같은 아이템을 동시에 주우려고 할 수 있다.
이를 막기 위해 채널링을 시작할 때 아이템을 예약하고, 예약된 아이템은 다른 생존자가 상호작용할 수 없게 했다.
bool ASPPickupItem::TryReserve(ASurvivorCharacter* Survivor)
{
if (!HasAuthority() || !IsValid(Survivor)
|| bStored || IsValid(ReservedBy))
{
return false;
}
ReservedBy = Survivor;
ForceNetUpdate();
return true;
}
줍기가 취소되면 예약을 해제한다.
if (ASPPickupItem* PickupItem = CurrentPickupItem.Get())
{
PickupItem->ReleaseReservation(Survivor);
}
줍기가 완료되면 서버에서 인벤토리에 저장하고, 월드 액터는 숨김 및 충돌 비활성화 상태가 된다.
void ASPPickupItem::ApplyStoredState()
{
SetActorHiddenInGame(bStored);
SetActorEnableCollision(!bStored);
if (bStored && Mesh)
{
Mesh->SetRenderCustomDepth(false);
}
}
상호작용 키는 월드 상호작용과 소모품 사용을 함께 처리한다.
먼저 전방에 상호작용 가능한 대상이 있는지 확인하고, 대상이 없다면 현재 선택한 슬롯의 소모품을 사용한다.
if (TraceInteractable(Hit)
&& Hit.GetActor()
&& Hit.GetActor()->Implements<USPInteractable>()
&& ISPInteractable::Execute_IsInteractable(Hit.GetActor()))
{
ISPInteractable::Execute_Interact(Hit.GetActor(), Survivor);
return;
}
TryUseSelectedConsumable();
소모품 사용은 슬롯에 저장된 타입으로 분기한다.
bool USPInteractionComponent::TryUseSelectedConsumable()
{
ASurvivorCharacter* Survivor = GetSurvivor();
USPInventoryComponent* Inventory =
Survivor ? Survivor->GetInventoryComponent() : nullptr;
if (!Survivor || !Inventory)
return false;
const int32 SlotIndex = Survivor->GetSelectedSlotIndex();
switch (Inventory->GetConsumableTypeAtSlot(SlotIndex))
{
case EConsumableItemType::Medkit:
return TryBeginSelfHeal();
case EConsumableItemType::SpeedPotion:
return TryBeginSpeedPotionUse();
default:
return false;
}
}
처음에는 상호작용 키 하나로 사용과 버리기를 구분하려고 했지만, 입력 시간이 복잡해지고 의도가 불명확해지는 문제가 있었다.
그래서 버리기는 별도의 IA_Drop으로 분리했다.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input|Action")
TObjectPtr<UInputAction> InteractAction;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input|Action")
TObjectPtr<UInputAction> DropAction;
생존자는 DA_InputConfig에 지정된 액션만 가져와 바인딩한다.
if (UInputAction* DropAction = InputConfig->DropAction.Get())
{
EnhancedInput->BindAction(
DropAction,
ETriggerEvent::Started,
this,
&ASurvivorCharacter::DropSelectedItem);
}
키 자체는 C++에서 지정하지 않고 Input Mapping Context에서 설정하도록 했다.
중첩된 소모품을 버릴 때는 슬롯 전체를 비우지 않고 수량 하나만 감소시킨다.
if (Slot.ContentType == EInventorySlotContentType::Consumable
&& Slot.Quantity > 1)
{
OutSourceItem = SpawnStackedItem(Slot);
if (!OutSourceItem)
return false;
--Slot.Quantity;
BroadcastInventoryChanged();
return true;
}
새로 생성되는 월드 아이템도 특정 C++ 클래스로 고정하지 않는다.
슬롯이 보관 중인 원본 아이템의 런타임 클래스를 사용하므로, 블루프린트에 설정한 Mesh와 Icon이 그대로 유지된다.
return GetWorld()->SpawnActor<ASPPickupItem>(
Slot.SourceItem->GetClass(),
GetOwner()->GetActorTransform(),
SpawnParameters);
수량이 하나만 남았다면 보관 중이던 SourceItem을 그대로 월드에 돌려놓고 슬롯을 비운다.
OutSourceItem = Slot.SourceItem;
Slot.Clear();
BroadcastInventoryChanged();
생존자가 다운되면 기존 수집품뿐 아니라 소모품도 모두 떨어뜨려야 한다.
상태가 Downed로 변경될 때 인벤토리 전체 드롭을 호출한다.
if (NewState == ESurvivorState::Downed)
{
InteractionComponent->DropAllItems();
}
중첩된 소모품도 수량이 없어질 때까지 반복해서 하나씩 월드에 생성한다.
for (int32 SlotIndex = 0;
SlotIndex < USPInventoryComponent::InventorySlotCount;
++SlotIndex)
{
while (Inventory->IsSlotOccupied(SlotIndex))
{
ASPPickupItem* Item = nullptr;
if (!Inventory->DropSlot(SlotIndex, Item) || !Item)
break;
Item->SetStored(false, DropLocation);
}
}
드롭 간격은 별도의 배율 값으로 고정하지 않고, 각 아이템의 실제 Bounds를 읽어 옆으로 배치했다.
덕분에 Mesh 크기가 다른 아이템이 추가되더라도 서로 완전히 겹치는 현상을 줄일 수 있다.
Medkit은 부상 상태인 생존자가 자신을 치료할 때 사용한다.
기존 치료 동작은 유지하되, 이제 인벤토리의 실제 Medkit 슬롯을 확인하고 채널링이 완료됐을 때만 하나를 소비한다.
bool USPInteractionComponent::TryBeginSelfHeal()
{
ASurvivorCharacter* Survivor = GetSurvivor();
const USurvivorData* Data = GetSurvivorData();
if (!Survivor || !Survivor->HasAuthority()
|| bIsInteract || !Data)
{
return false;
}
if (Survivor->GetSurvivorState() != ESurvivorState::Injured || !IsSelectedSlotMedkit())
{
return false;
}
bIsSelfHealing = true;
bIsInteract = true;
ActiveConsumableSlotIndex = Survivor->GetSelectedSlotIndex();
GetWorld()->GetTimerManager().SetTimer(
HealTimer, this, &USPInteractionComponent::CompleteHeal,
Data->MedkitDuration, false);
return true;
}
완료 시점에도 시작할 때 저장한 슬롯에 Medkit이 남아 있는지 다시 확인한다.
const bool bConsumed =
Inventory->ConsumeConsumableAtSlot(ActiveConsumableSlotIndex,
EConsumableItemType::Medkit);
if (bConsumed)
{
Survivor->RecoverOneStep();
}
아이템의 표기는 Medikit이 아니라 올바른 철자인 Medkit으로 통일했다.
Speed Potion도 즉시 사용되지 않고 Medkit처럼 채널링하도록 만들었다.
다만 기본 사용 시간은 Medkit 3초의 절반인 1.5초로 설정했다.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Items")
float MedkitDuration = 3.00;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Items")
float SpeedPotionUseDuration = 1.50;
이 값은 DA_SurvivorData에서 수정할 수 있으므로 기획 변경이 생겨도 C++을 다시 수정할 필요가 없다.
채널링을 시작할 때는 다음 조건을 검사한다.
Healthy 또는 Injured인가bool USPInteractionComponent::TryBeginSpeedPotionUse()
{
ASurvivorCharacter* Survivor = GetSurvivor();
USPInventoryComponent* Inventory = Survivor ? Survivor->GetInventoryComponent() : nullptr;
USPMovementComponent* Movement = Survivor ? Survivor->GetSPMovementComponent() : nullptr;
const USurvivorData* Data = GetSurvivorData();
const int32 SlotIndex = Survivor ? Survivor->GetSelectedSlotIndex() : INDEX_NONE;
if (!Survivor || !Survivor->HasAuthority() || bIsInteract || !Inventory || !Movement
|| !Data || !Movement->CanActivateSpeedPotion() ||
!Inventory->IsSlotConsumable(SlotIndex, EConsumableItemType::SpeedPotion))
{
return false;
}
bIsUsingSpeedPotion = true;
bIsInteract = true;
ActiveConsumableSlotIndex = SlotIndex;
PlayInteractMontage(SpeedPotionUseMontage.LoadSynchronous());
GetWorld()->GetTimerManager().SetTimer(
SpeedPotionUseTimer, this, &USPInteractionComponent::CompleteSpeedPotionUse,
Data->SpeedPotionUseDuration, false);
return true;
}
아이템 소비와 효과 적용은 채널링 완료 시점에만 처리한다.
if (Inventory->ConsumeConsumableAtSlot(ActiveConsumableSlotIndex,
EConsumableItemType::SpeedPotion))
{
const bool bSkipFatigue = Inventory->HasPerk(EPerkType::LightWheels);
Movement->TryActivateSpeedPotion(bSkipFatigue);
}
채널링이 취소되면 타이머와 상태만 초기화한다.
void USPInteractionComponent::CancelSpeedPotionChannel()
{
GetWorld()->GetTimerManager().ClearTimer(SpeedPotionUseTimer);
bIsUsingSpeedPotion = false;
ActiveConsumableSlotIndex = INDEX_NONE;
}
따라서 채널링 도중 R을 놓거나 움직여도 Speed Potion은 사라지지 않는다.
Speed Potion의 인벤토리 수량은 중첩할 수 있지만, 이동속도 효과는 중첩하지 않는다.
효과 상태를 별도 enum으로 관리했다.
UENUM(BlueprintType)
enum class ESpeedPotionPhase : uint8
{
None,
Boost,
Fatigue
};
현재 상태가 None일 때만 새로운 Speed Potion 효과를 시작할 수 있다.
bool USPMovementComponent::CanActivateSpeedPotion() const
{
const ASurvivorCharacter* Survivor = GetSurvivor();
const USurvivorData* Data = GetSurvivorData();
if (!Survivor || !Data || SpeedPotionPhase != ESpeedPotionPhase::None)
{
return false;
}
const ESurvivorState State = Survivor->GetSurvivorState();
return State == ESurvivorState::Healthy || State == ESurvivorState::Injured;
}
이동속도 계산에서는 기존 속도에 Speed Potion 배율을 곱한다.
float USPMovementComponent::ComputeTargetMoveSpeed() const
{
const float BaseSpeed = bHitEscapeSprintActive ? HitEscapeSprintSpeed
: GetBaseWalkSpeed();
return BaseSpeed * GetCarryMoveSpeedMultiplier() * GetSpeedPotionMultiplier();
}
다음과 같이 소모품이 잘 중첩되고 사용되는 것을 확인할 수 있다.
