1479 lines
44 KiB
C++
1479 lines
44 KiB
C++
// Fill out your copyright notice in the Description page of Project Settings.
|
|
|
|
|
|
#include "QuestSubsystem.h"
|
|
|
|
#include "Engine/AssetManager.h"
|
|
#include "Engine/World.h"
|
|
#include "GameFramework/Pawn.h"
|
|
#include "GameFramework/PlayerController.h"
|
|
#include "GameplayTagAssetInterface.h"
|
|
#include "Kismet/GameplayStatics.h"
|
|
#include "TimerManager.h"
|
|
|
|
#include "InventoryComponent.h"
|
|
#include "MyCharacter.h"
|
|
#include "ProjectEleri/Ai/NpcScheduleComponent.h"
|
|
#include "ProjectEleri/DialogueSystem/DialogueSceneSubsystem.h"
|
|
#include "ProjectEleri/DialogueSystem/DialogueSubsystem.h"
|
|
#include "ProjectEleri/GameEventSystem/GameEventSubsystem.h"
|
|
#include "ProjectEleri/Items/DataAssets/ItemDataAsset.h"
|
|
#include "ProjectEleri/Public/EleriGameState.h"
|
|
#include "ProjectEleri/QuestSystem/QuestDataAsset.h"
|
|
#include "ProjectEleri/Settings/EleriGameSettings.h"
|
|
#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h"
|
|
#include "Subsystem/InteractionSubsystem.h"
|
|
#include "TimeOfDayManager.h"
|
|
|
|
UQuestSubsystem* UQuestSubsystem::Get(const UObject* WorldContext)
|
|
{
|
|
UGameInstance* GameInstance = UGameplayStatics::GetGameInstance(WorldContext);
|
|
if (GameInstance == nullptr)
|
|
{
|
|
return nullptr;
|
|
}
|
|
|
|
UQuestSubsystem* Subsystem = GameInstance->GetSubsystem<UQuestSubsystem>();
|
|
|
|
// Asking for the quest subsystem is also what wakes it up. Everything a quest watches in a level is
|
|
// bound here rather than only at the start of play, so a subsystem that missed a world because it
|
|
// was created after that world had already begun still picks it up on the first query.
|
|
if (Subsystem != nullptr && WorldContext != nullptr)
|
|
{
|
|
Subsystem->EnsureGameplayBound(WorldContext->GetWorld());
|
|
}
|
|
|
|
return Subsystem;
|
|
}
|
|
|
|
void UQuestSubsystem::Initialize(FSubsystemCollectionBase& Collection)
|
|
{
|
|
Super::Initialize(Collection);
|
|
|
|
bQuestsLoaded = false;
|
|
|
|
// The registry sweep is asynchronous because it goes through the asset manager, the same way the
|
|
// event and dialogue subsystems read theirs, and it is the only thing here that costs anything at
|
|
// startup.
|
|
if (UAssetManager* AssetManager = UAssetManager::GetIfInitialized())
|
|
{
|
|
FStreamableManager& StreamableManager = AssetManager->GetStreamableManager();
|
|
|
|
TArray<FAssetData> AssetDataList;
|
|
AssetManager->GetAssetRegistry().GetAssetsByClass(
|
|
FTopLevelAssetPath(UQuestDataAsset::StaticClass()->GetPathName()), AssetDataList, false);
|
|
|
|
AssetPathsToLoad.Reset();
|
|
for (const FAssetData& AssetData : AssetDataList)
|
|
{
|
|
AssetPathsToLoad.AddUnique(AssetData.GetSoftObjectPath());
|
|
}
|
|
|
|
if (AssetPathsToLoad.Num() == 0)
|
|
{
|
|
// No quests in the project yet. Saying so now rather than leaving the subsystem waiting on
|
|
// a load that has nothing in it keeps "no quests" and "quests not read yet" apart.
|
|
QuestAssetsLoaded();
|
|
}
|
|
else
|
|
{
|
|
StreamableManager.RequestAsyncLoad(
|
|
AssetPathsToLoad, FStreamableDelegate::CreateUObject(this, &UQuestSubsystem::QuestAssetsLoaded));
|
|
}
|
|
}
|
|
|
|
PostWorldInitializationHandle = FWorldDelegates::OnPostWorldInitialization.AddUObject(
|
|
this, &UQuestSubsystem::HandlePostWorldInitialization);
|
|
WorldCleanupHandle = FWorldDelegates::OnWorldCleanup.AddUObject(
|
|
this, &UQuestSubsystem::HandleWorldCleanup);
|
|
}
|
|
|
|
void UQuestSubsystem::Deinitialize()
|
|
{
|
|
ReleaseGameplayBindings();
|
|
|
|
if (PostWorldInitializationHandle.IsValid())
|
|
{
|
|
FWorldDelegates::OnPostWorldInitialization.Remove(PostWorldInitializationHandle);
|
|
PostWorldInitializationHandle.Reset();
|
|
}
|
|
|
|
if (WorldCleanupHandle.IsValid())
|
|
{
|
|
FWorldDelegates::OnWorldCleanup.Remove(WorldCleanupHandle);
|
|
WorldCleanupHandle.Reset();
|
|
}
|
|
|
|
Super::Deinitialize();
|
|
}
|
|
|
|
void UQuestSubsystem::QuestAssetsLoaded()
|
|
{
|
|
QuestAssets.Reset();
|
|
|
|
for (const FSoftObjectPath& Path : AssetPathsToLoad)
|
|
{
|
|
UQuestDataAsset* QuestAsset = Cast<UQuestDataAsset>(Path.ResolveObject());
|
|
if (QuestAsset == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
FString Error;
|
|
if (!QuestAsset->IsValidQuest(Error))
|
|
{
|
|
UE_LOG(LogTemp, Warning, TEXT("Quest asset '%s' is not usable: %s."), *QuestAsset->GetName(), *Error);
|
|
continue;
|
|
}
|
|
|
|
if (const UQuestDataAsset* Existing = GetQuestByTag(QuestAsset->QuestTag))
|
|
{
|
|
UE_LOG(LogTemp, Warning, TEXT("Quest asset '%s' uses tag %s, which '%s' already uses. It is ignored."),
|
|
*QuestAsset->GetName(), *QuestAsset->QuestTag.ToString(), *Existing->GetName());
|
|
continue;
|
|
}
|
|
|
|
QuestAssets.Add(QuestAsset);
|
|
}
|
|
|
|
AssetPathsToLoad.Reset();
|
|
bQuestsLoaded = true;
|
|
|
|
UE_LOG(LogTemp, Log, TEXT("Quest subsystem loaded %d quests."), QuestAssets.Num());
|
|
OnQuestAssetsLoaded.Broadcast();
|
|
}
|
|
|
|
UQuestDataAsset* UQuestSubsystem::GetQuestByTag(FGameplayTag QuestTag) const
|
|
{
|
|
if (!QuestTag.IsValid())
|
|
{
|
|
return nullptr;
|
|
}
|
|
|
|
for (const TObjectPtr<UQuestDataAsset>& Quest : QuestAssets)
|
|
{
|
|
if (Quest != nullptr && Quest->QuestTag == QuestTag)
|
|
{
|
|
return Quest;
|
|
}
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
void UQuestSubsystem::HandlePostWorldInitialization(UWorld* World, const FWorldInitializationValues IVS)
|
|
{
|
|
if (World == nullptr || !World->IsGameWorld())
|
|
{
|
|
return;
|
|
}
|
|
|
|
// The previous world, if there is one, is left to its cleanup: this subsystem follows the player,
|
|
// and a listen server's other worlds are not where the quests are being played.
|
|
if (UWorld* PreviousWorld = BoundWorld.Get())
|
|
{
|
|
if (WorldBeginPlayHandle.IsValid())
|
|
{
|
|
PreviousWorld->OnWorldBeginPlay.Remove(WorldBeginPlayHandle);
|
|
WorldBeginPlayHandle.Reset();
|
|
}
|
|
}
|
|
|
|
BoundWorld = World;
|
|
|
|
// Begin play rather than the initialization this is called from, because the player controller and
|
|
// its pawn, which every binding below hangs off, do not exist yet at this point.
|
|
WorldBeginPlayHandle = World->OnWorldBeginPlay.AddUObject(this, &UQuestSubsystem::HandleWorldBeginPlay);
|
|
}
|
|
|
|
void UQuestSubsystem::HandleWorldBeginPlay()
|
|
{
|
|
EnsureGameplayBound(BoundWorld.Get());
|
|
}
|
|
|
|
void UQuestSubsystem::HandleWorldCleanup(UWorld* World, bool bSessionEnded, bool bCleanupResources)
|
|
{
|
|
if (World == nullptr || World != BoundWorld.Get())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (WorldBeginPlayHandle.IsValid())
|
|
{
|
|
World->OnWorldBeginPlay.Remove(WorldBeginPlayHandle);
|
|
WorldBeginPlayHandle.Reset();
|
|
}
|
|
|
|
ReleaseGameplayBindings();
|
|
BoundWorld.Reset();
|
|
}
|
|
|
|
void UQuestSubsystem::EnsureGameplayBound(UWorld* World)
|
|
{
|
|
if (World == nullptr || !World->IsGameWorld() || World != BoundWorld.Get())
|
|
{
|
|
// A world that was never announced has no begin play to carry the first binding, and there is
|
|
// nothing to bind to in a world that is not the player's.
|
|
return;
|
|
}
|
|
|
|
APlayerController* PlayerController = World->GetFirstPlayerController();
|
|
AMyCharacter* Character = PlayerController != nullptr ? Cast<AMyCharacter>(PlayerController->GetPawn()) : nullptr;
|
|
UInventoryComponent* Inventory = Character != nullptr ? Character->GetComponentByClass<UInventoryComponent>() : nullptr;
|
|
|
|
const bool bRebind = BoundPlayerController.Get() != PlayerController
|
|
|| BoundCharacter.Get() != Character
|
|
|| BoundInventory.Get() != Inventory;
|
|
|
|
if (!bRebind)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ReleaseGameplayBindings();
|
|
|
|
BoundPlayerController = PlayerController;
|
|
BoundCharacter = Character;
|
|
BoundInventory = Inventory;
|
|
|
|
// A new controller means new dynamic bindings all round: the controller announces pawn changes,
|
|
// the pawn announces interactions, the inventory announces its own contents, and none of those
|
|
// survive the pawn being replaced.
|
|
if (PlayerController != nullptr)
|
|
{
|
|
PlayerController->OnPossessedPawnChanged.AddDynamic(this, &UQuestSubsystem::HandlePossessedPawnChanged);
|
|
}
|
|
|
|
if (Character != nullptr)
|
|
{
|
|
Character->OnInteract.AddDynamic(this, &UQuestSubsystem::HandlePlayerInteract);
|
|
}
|
|
|
|
if (Inventory != nullptr)
|
|
{
|
|
Inventory->OnInventoryUpdated.AddDynamic(this, &UQuestSubsystem::HandleInventoryUpdated);
|
|
}
|
|
|
|
// The dialogue subsystem is a game instance subsystem and so survives travel, but it is bound here
|
|
// because this is where it is guaranteed to be alive: nothing about a conversation needs a world.
|
|
if (UGameInstance* GameInstance = GetGameInstance())
|
|
{
|
|
if (UDialogueSubsystem* DialogueSubsystem = GameInstance->GetSubsystem<UDialogueSubsystem>())
|
|
{
|
|
DialogueSubsystem->OnDialogueSpeakerChanged.AddDynamic(this, &UQuestSubsystem::HandleDialogueSpeakerChanged);
|
|
DialogueSubsystem->OnDialogueGameplayEvent.AddDynamic(this, &UQuestSubsystem::HandleDialogueGameplayEvent);
|
|
}
|
|
}
|
|
|
|
// The clock is respawned with the level, so its day change is bound per level even though the
|
|
// quests that measure deadlines against it are not.
|
|
if (ATimeOfDayManager* TimeOfDayManager = GetTimeOfDayManager())
|
|
{
|
|
TimeOfDayManager->OnDayPassed.AddDynamic(this, &UQuestSubsystem::HandleDayPassed);
|
|
}
|
|
|
|
UE_LOG(LogTemp, Log, TEXT("Quest subsystem bound in world '%s' (controller: %s, character: %s)."),
|
|
*World->GetName(),
|
|
PlayerController != nullptr ? *PlayerController->GetName() : TEXT("none"),
|
|
Character != nullptr ? *Character->GetName() : TEXT("none"));
|
|
|
|
// Whatever is already being carried counts towards the steps that ask for an item, and a quest that
|
|
// was part way through when the player travelled needs its steps re-read in the new world.
|
|
RefreshActiveQuests();
|
|
}
|
|
|
|
void UQuestSubsystem::ReleaseGameplayBindings()
|
|
{
|
|
if (APlayerController* PlayerController = BoundPlayerController.Get())
|
|
{
|
|
PlayerController->OnPossessedPawnChanged.RemoveAll(this);
|
|
}
|
|
|
|
if (AMyCharacter* Character = BoundCharacter.Get())
|
|
{
|
|
Character->OnInteract.RemoveAll(this);
|
|
}
|
|
|
|
if (UInventoryComponent* Inventory = BoundInventory.Get())
|
|
{
|
|
Inventory->OnInventoryUpdated.RemoveAll(this);
|
|
}
|
|
|
|
if (UGameInstance* GameInstance = GetGameInstance())
|
|
{
|
|
if (UDialogueSubsystem* DialogueSubsystem = GameInstance->GetSubsystem<UDialogueSubsystem>())
|
|
{
|
|
DialogueSubsystem->OnDialogueSpeakerChanged.RemoveAll(this);
|
|
DialogueSubsystem->OnDialogueGameplayEvent.RemoveAll(this);
|
|
}
|
|
}
|
|
|
|
if (ATimeOfDayManager* TimeOfDayManager = CachedTimeOfDayManager.Get())
|
|
{
|
|
TimeOfDayManager->OnDayPassed.RemoveAll(this);
|
|
}
|
|
|
|
BoundPlayerController.Reset();
|
|
BoundCharacter.Reset();
|
|
BoundInventory.Reset();
|
|
}
|
|
|
|
UObject* UQuestSubsystem::GetTestContext() const
|
|
{
|
|
if (const AMyCharacter* Character = BoundCharacter.Get())
|
|
{
|
|
return const_cast<AMyCharacter*>(Character);
|
|
}
|
|
|
|
// Before a pawn exists, or after a respawn the binding has not caught up with, the player
|
|
// character of the world is the same thing the event system hands its own requirements.
|
|
const UWorld* World = GetBoundWorld();
|
|
return World != nullptr ? UGameplayStatics::GetPlayerCharacter(World, 0) : nullptr;
|
|
}
|
|
|
|
UInventoryComponent* UQuestSubsystem::GetPlayerInventory() const
|
|
{
|
|
return BoundInventory.Get();
|
|
}
|
|
|
|
ATimeOfDayManager* UQuestSubsystem::GetTimeOfDayManager()
|
|
{
|
|
if (CachedTimeOfDayManager.IsValid())
|
|
{
|
|
return CachedTimeOfDayManager.Get();
|
|
}
|
|
|
|
UWorld* World = GetBoundWorld();
|
|
if (World == nullptr)
|
|
{
|
|
return nullptr;
|
|
}
|
|
|
|
// Cached, because this is asked for on every deadline check and GetActorOfClass walks the level.
|
|
ATimeOfDayManager* Found = Cast<ATimeOfDayManager>(UGameplayStatics::GetActorOfClass(World, ATimeOfDayManager::StaticClass()));
|
|
CachedTimeOfDayManager = Found;
|
|
|
|
return Found;
|
|
}
|
|
|
|
int32 UQuestSubsystem::GetCurrentDay()
|
|
{
|
|
const ATimeOfDayManager* TimeOfDayManager = GetTimeOfDayManager();
|
|
return TimeOfDayManager != nullptr ? TimeOfDayManager->GetAllDaysPassed() : 0;
|
|
}
|
|
|
|
FGameplayTag UQuestSubsystem::ResolveActorNpcTag(const AActor* Actor)
|
|
{
|
|
if (Actor == nullptr)
|
|
{
|
|
return FGameplayTag();
|
|
}
|
|
|
|
// Scheduled characters carry their tag on their schedule component, which is the tag their day
|
|
// plan and their friendship are keyed by, so a talk step names the same thing a schedule does.
|
|
if (const UNpcScheduleComponent* Schedule = Actor->FindComponentByClass<UNpcScheduleComponent>())
|
|
{
|
|
if (Schedule->NpcTag.IsValid())
|
|
{
|
|
return Schedule->NpcTag;
|
|
}
|
|
}
|
|
|
|
// Anything else that owns gameplay tags is matched by the first of its tags under NpcTags, which
|
|
// is how a door or a chest can be the target of a step without having a schedule at all.
|
|
if (const IGameplayTagAssetInterface* TagInterface = Cast<IGameplayTagAssetInterface>(Actor))
|
|
{
|
|
static const FGameplayTag NpcTagRoot = FGameplayTag::RequestGameplayTag(FName(TEXT("NpcTags")), false);
|
|
|
|
FGameplayTagContainer OwnedTags;
|
|
TagInterface->GetOwnedGameplayTags(OwnedTags);
|
|
|
|
for (const FGameplayTag& Tag : OwnedTags)
|
|
{
|
|
if (NpcTagRoot.IsValid() && Tag.MatchesTag(NpcTagRoot))
|
|
{
|
|
return Tag;
|
|
}
|
|
}
|
|
}
|
|
|
|
return FGameplayTag();
|
|
}
|
|
|
|
EQuestStatus UQuestSubsystem::GetQuestStatus(FGameplayTag QuestTag) const
|
|
{
|
|
if (const FQuestRuntimeState* State = QuestStates.Find(QuestTag))
|
|
{
|
|
// An offer is nothing more than the requirements holding, so a quest that has been put forward
|
|
// and has since stopped qualifying stops being on offer. Nothing is written back for this: the
|
|
// entry keeps saying Available and the answer here is what changes, which is what makes the
|
|
// offer come back by itself if the conditions come back.
|
|
if (State->Status == EQuestStatus::Available)
|
|
{
|
|
const UQuestDataAsset* Quest = GetQuestByTag(QuestTag);
|
|
if (Quest == nullptr || !Quest->AreOfferRequirementsMet(GetTestContext()))
|
|
{
|
|
return EQuestStatus::Unavailable;
|
|
}
|
|
}
|
|
|
|
return State->Status;
|
|
}
|
|
|
|
// Nothing has happened to this quest yet, so the useful answer is whether the player could take it.
|
|
// That is worked out from the offer requirements every time rather than remembered, which is what
|
|
// lets a quest become takeable because a friendship grew or a day passed, with nothing having to
|
|
// announce it.
|
|
const UQuestDataAsset* Quest = GetQuestByTag(QuestTag);
|
|
if (Quest == nullptr)
|
|
{
|
|
return EQuestStatus::Unavailable;
|
|
}
|
|
|
|
return Quest->AreOfferRequirementsMet(GetTestContext()) ? EQuestStatus::Available : EQuestStatus::Unavailable;
|
|
}
|
|
|
|
bool UQuestSubsystem::IsQuestInProgress(FGameplayTag QuestTag) const
|
|
{
|
|
const EQuestStatus Status = GetQuestStatus(QuestTag);
|
|
return Status == EQuestStatus::Active || Status == EQuestStatus::ReadyToTurnIn;
|
|
}
|
|
|
|
bool UQuestSubsystem::CanAcceptQuest(FGameplayTag QuestTag) const
|
|
{
|
|
const UQuestDataAsset* Quest = GetQuestByTag(QuestTag);
|
|
if (Quest == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const EQuestStatus Status = GetQuestStatus(QuestTag);
|
|
|
|
// A quest already being worked on cannot be taken twice, and one that has been handed in is
|
|
// finished with. Failed and abandoned quests are left takeable, which is what makes a second
|
|
// attempt possible without anything having to reset the save.
|
|
if (Status == EQuestStatus::Active || Status == EQuestStatus::ReadyToTurnIn || Status == EQuestStatus::Completed)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return Quest->AreOfferRequirementsMet(GetTestContext());
|
|
}
|
|
|
|
int32 UQuestSubsystem::GetObjectiveProgress(FGameplayTag QuestTag, FName ObjectiveId) const
|
|
{
|
|
if (const FQuestRuntimeState* State = QuestStates.Find(QuestTag))
|
|
{
|
|
if (const FQuestObjectiveProgress* Progress = State->FindProgress(ObjectiveId))
|
|
{
|
|
return Progress->CurrentCount;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
FQuestJournalEntry UQuestSubsystem::BuildJournalEntry(const UQuestDataAsset* Quest) const
|
|
{
|
|
FQuestJournalEntry Entry;
|
|
if (Quest == nullptr)
|
|
{
|
|
return Entry;
|
|
}
|
|
|
|
Entry.Quest = const_cast<UQuestDataAsset*>(Quest);
|
|
Entry.QuestTag = Quest->QuestTag;
|
|
Entry.QuestName = Quest->QuestName;
|
|
Entry.QuestDescription = Quest->QuestDescription;
|
|
Entry.Status = GetQuestStatus(Quest->QuestTag);
|
|
Entry.bReadyToTurnIn = Entry.Status == EQuestStatus::ReadyToTurnIn;
|
|
|
|
const FQuestRuntimeState* State = QuestStates.Find(Quest->QuestTag);
|
|
|
|
for (const FQuestObjective& Objective : Quest->Objectives)
|
|
{
|
|
// Hidden steps are tracked but never shown, which is how a quest keeps a secret count of
|
|
// something the player is not supposed to know it is watching.
|
|
if (Objective.bHidden)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
FQuestObjectiveView View;
|
|
View.ObjectiveId = Objective.ObjectiveId;
|
|
View.Description = Objective.Description;
|
|
View.RequiredCount = FMath::Max(1, Objective.RequiredCount);
|
|
View.bOptional = Objective.bOptional;
|
|
|
|
if (const FQuestObjectiveProgress* Progress = State != nullptr ? State->FindProgress(Objective.ObjectiveId) : nullptr)
|
|
{
|
|
View.CurrentCount = Progress->CurrentCount;
|
|
View.bComplete = Progress->bComplete;
|
|
}
|
|
|
|
Entry.Objectives.Add(View);
|
|
}
|
|
|
|
return Entry;
|
|
}
|
|
|
|
TArray<FQuestJournalEntry> UQuestSubsystem::GetTrackedQuestEntries() const
|
|
{
|
|
TArray<FQuestJournalEntry> Entries;
|
|
|
|
for (const TPair<FGameplayTag, FQuestRuntimeState>& Pair : QuestStates)
|
|
{
|
|
if (Pair.Value.Quest == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (Pair.Value.Status == EQuestStatus::Active || Pair.Value.Status == EQuestStatus::ReadyToTurnIn)
|
|
{
|
|
Entries.Add(BuildJournalEntry(Pair.Value.Quest));
|
|
}
|
|
}
|
|
|
|
// A map has no order of its own, and a journal that reshuffles itself between two openings is
|
|
// worse than one in no particular order, so the list is sorted into the one order that is stable
|
|
// whatever the player has been doing.
|
|
Entries.Sort([](const FQuestJournalEntry& A, const FQuestJournalEntry& B)
|
|
{
|
|
return A.QuestTag.ToString() < B.QuestTag.ToString();
|
|
});
|
|
|
|
return Entries;
|
|
}
|
|
|
|
TArray<FQuestJournalEntry> UQuestSubsystem::GetOfferableQuestEntries() const
|
|
{
|
|
return GetQuestEntries(EQuestStatus::Available);
|
|
}
|
|
|
|
TArray<FQuestJournalEntry> UQuestSubsystem::GetQuestEntries(EQuestStatus Status) const
|
|
{
|
|
TArray<FQuestJournalEntry> Entries;
|
|
|
|
for (const TObjectPtr<UQuestDataAsset>& Quest : QuestAssets)
|
|
{
|
|
if (Quest != nullptr && GetQuestStatus(Quest->QuestTag) == Status)
|
|
{
|
|
Entries.Add(BuildJournalEntry(Quest));
|
|
}
|
|
}
|
|
|
|
return Entries;
|
|
}
|
|
|
|
FQuestRuntimeState& UQuestSubsystem::FindOrAddState(FGameplayTag QuestTag)
|
|
{
|
|
if (FQuestRuntimeState* Existing = QuestStates.Find(QuestTag))
|
|
{
|
|
return *Existing;
|
|
}
|
|
|
|
FQuestRuntimeState& State = QuestStates.Add(QuestTag);
|
|
State.Quest = GetQuestByTag(QuestTag);
|
|
State.Status = EQuestStatus::Unavailable;
|
|
|
|
return State;
|
|
}
|
|
|
|
void UQuestSubsystem::SetQuestStatus(FQuestRuntimeState& State, EQuestStatus NewStatus)
|
|
{
|
|
if (State.Status == NewStatus)
|
|
{
|
|
return;
|
|
}
|
|
|
|
State.Status = NewStatus;
|
|
|
|
// The asset is read out before the broadcast, because a listener is free to start or finish another
|
|
// quest, which can reallocate the map this state lives in.
|
|
UQuestDataAsset* Quest = State.Quest;
|
|
OnQuestStatusChanged.Broadcast(Quest, NewStatus);
|
|
|
|
// The stage's cutscene comes after the broadcast rather than before it, so a listener that reacts to
|
|
// the new status has already reacted by the time the scene takes the player's controls, and so the
|
|
// quest asset is not being read while that listener may still be writing to the quest map.
|
|
PlayStatusScene(Quest, NewStatus);
|
|
}
|
|
|
|
void UQuestSubsystem::PlayStatusScene(const UQuestDataAsset* Quest, EQuestStatus NewStatus)
|
|
{
|
|
if (Quest == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const TSoftObjectPtr<ULevelSequence>* Scene = Quest->StatusScenes.Find(NewStatus);
|
|
if (Scene == nullptr || Scene->IsNull())
|
|
{
|
|
// Most quests have no scene for most stages, which is the ordinary case and not a fault.
|
|
return;
|
|
}
|
|
|
|
if (UDialogueSceneSubsystem* SceneSubsystem = UDialogueSceneSubsystem::Get(this))
|
|
{
|
|
// The player goes in as the instigator, because a quest is not an actor and the thing a scene
|
|
// listener wants to know is whose scene this is.
|
|
SceneSubsystem->PlayScene(*Scene, Cast<AActor>(GetTestContext()));
|
|
}
|
|
}
|
|
|
|
bool UQuestSubsystem::OfferQuest(FGameplayTag QuestTag)
|
|
{
|
|
const UQuestDataAsset* Quest = GetQuestByTag(QuestTag);
|
|
if (Quest == nullptr || !Quest->AreOfferRequirementsMet(GetTestContext()))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const EQuestStatus Status = GetQuestStatus(QuestTag);
|
|
|
|
// A quest nobody has accepted is already available the moment its requirements hold, so the only
|
|
// thing this does is make the offer something the game can announce. A quest that was failed or
|
|
// abandoned is left alone: taking it up again is StartQuest's business, because that is the call
|
|
// that resets its progress.
|
|
if (Status == EQuestStatus::Available || Status == EQuestStatus::Unavailable)
|
|
{
|
|
SetQuestStatus(FindOrAddState(QuestTag), EQuestStatus::Available);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool UQuestSubsystem::StartQuest(FGameplayTag QuestTag)
|
|
{
|
|
UQuestDataAsset* Quest = GetQuestByTag(QuestTag);
|
|
if (Quest == nullptr || !CanAcceptQuest(QuestTag))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
FQuestRuntimeState& State = FindOrAddState(QuestTag);
|
|
State.Quest = Quest;
|
|
State.AcceptedDay = GetCurrentDay();
|
|
State.CompletedDay = 0;
|
|
State.Objectives.Reset();
|
|
|
|
// Every step starts at zero in the order the asset lists them, so the journal lists the quest the
|
|
// way it was written from the first frame it is visible.
|
|
for (const FQuestObjective& Objective : Quest->Objectives)
|
|
{
|
|
FQuestObjectiveProgress Progress;
|
|
Progress.ObjectiveId = Objective.ObjectiveId;
|
|
Progress.CurrentCount = 0;
|
|
Progress.bComplete = false;
|
|
Progress.bCountedFromInventory = Objective.StepType == EQuestStepType::AcquireItems
|
|
|| Objective.StepType == EQuestStepType::DeliverItems;
|
|
|
|
State.Objectives.Add(Progress);
|
|
}
|
|
|
|
SetQuestStatus(State, EQuestStatus::Active);
|
|
|
|
// A quest can be handed to a player who is already carrying what it asks for, and a quest handed
|
|
// over by a conversation may have been finished by that same conversation, so the steps are read
|
|
// once here rather than waiting for the next thing the player does.
|
|
RefreshActiveQuests();
|
|
|
|
return true;
|
|
}
|
|
|
|
bool UQuestSubsystem::AbandonQuest(FGameplayTag QuestTag)
|
|
{
|
|
FQuestRuntimeState* State = QuestStates.Find(QuestTag);
|
|
if (State == nullptr || !IsQuestInProgress(QuestTag))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// How far it had got is kept, so an abandoned quest that is taken up again reads as one the player
|
|
// has already made progress on rather than as one that was never touched.
|
|
SetQuestStatus(*State, EQuestStatus::Abandoned);
|
|
|
|
return true;
|
|
}
|
|
|
|
bool UQuestSubsystem::FailQuest(FGameplayTag QuestTag)
|
|
{
|
|
FQuestRuntimeState* State = QuestStates.Find(QuestTag);
|
|
if (State == nullptr || State->Status == EQuestStatus::Failed || State->Status == EQuestStatus::Completed)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
SetQuestStatus(*State, EQuestStatus::Failed);
|
|
|
|
return true;
|
|
}
|
|
|
|
bool UQuestSubsystem::CompleteQuest(FGameplayTag QuestTag)
|
|
{
|
|
FQuestRuntimeState* State = QuestStates.Find(QuestTag);
|
|
if (State == nullptr || State->Quest == nullptr || State->Status != EQuestStatus::ReadyToTurnIn)
|
|
{
|
|
// Asking to hand in a quest that is not finished, or one that was handed in already, is a
|
|
// script that got ahead of the player rather than a fault: nothing happens either way.
|
|
return false;
|
|
}
|
|
|
|
if (!State->Quest->AreTurnInRequirementsMet(GetTestContext()))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
CompleteQuestInternal(*State);
|
|
|
|
return true;
|
|
}
|
|
|
|
void UQuestSubsystem::CompleteQuestInternal(FQuestRuntimeState& State)
|
|
{
|
|
UQuestDataAsset* Quest = State.Quest;
|
|
if (Quest == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const FGameplayTag QuestTag = Quest->QuestTag;
|
|
|
|
// The delivery is taken first, so that a reward of the same item cannot be mistaken for the item
|
|
// the player was asked to bring.
|
|
if (Quest->bConsumeDeliveredItems && Quest->HasDeliverStep())
|
|
{
|
|
if (UInventoryComponent* Inventory = GetPlayerInventory())
|
|
{
|
|
TArray<TPair<FPrimaryAssetId, int32>> Deliverables;
|
|
Quest->CollectDeliverables(State, Deliverables);
|
|
|
|
for (const TPair<FPrimaryAssetId, int32>& Deliverable : Deliverables)
|
|
{
|
|
Inventory->RemoveItemFromInventory(Deliverable.Key, Deliverable.Value);
|
|
}
|
|
}
|
|
}
|
|
|
|
State.CompletedDay = GetCurrentDay();
|
|
|
|
// The inventory is deaf while the rewards are handed over and every running quest is read once
|
|
// afterwards instead, so that a reward cannot count towards a step of a quest still in flight.
|
|
bGrantingRewards = true;
|
|
GrantRewards(*Quest);
|
|
bGrantingRewards = false;
|
|
|
|
// Re-found rather than reused: granting a reward changes the inventory, which the inventory binding
|
|
// turns into a re-read of every running quest, and that can add to the map this state lives in.
|
|
if (FQuestRuntimeState* Refreshed = QuestStates.Find(QuestTag))
|
|
{
|
|
SetQuestStatus(*Refreshed, EQuestStatus::Completed);
|
|
}
|
|
|
|
RefreshActiveQuests();
|
|
}
|
|
|
|
void UQuestSubsystem::GrantRewards(const UQuestDataAsset& Quest)
|
|
{
|
|
const FQuestRewardBundle& Rewards = Quest.Rewards;
|
|
|
|
if (UInventoryComponent* Inventory = GetPlayerInventory())
|
|
{
|
|
for (const FQuestItemReward& Reward : Rewards.Items)
|
|
{
|
|
const FPrimaryAssetId ItemId = Reward.GetItemId();
|
|
if (!ItemId.IsValid())
|
|
{
|
|
UE_LOG(LogTemp, Warning, TEXT("Quest '%s' rewards an item that could not be resolved, so it was not given."),
|
|
*Quest.GetName());
|
|
continue;
|
|
}
|
|
|
|
Inventory->AddItemToInventory(ItemId, FMath::Max(1, Reward.Amount), Reward.bHighQuality);
|
|
}
|
|
|
|
for (const TPair<FGameplayTag, int32>& SpecialItem : Rewards.SpecialItems)
|
|
{
|
|
// Gold and the crystals are items like any other, addressed by tag through the same map the
|
|
// shop and the alchemy bench use, so a quest reward needs no path of its own to them.
|
|
const FPrimaryAssetId ItemId = UEleriGameSettings::GetSpecialItemId(SpecialItem.Key);
|
|
if (!ItemId.IsValid())
|
|
{
|
|
UE_LOG(LogTemp, Warning, TEXT("Quest '%s' rewards '%s', which is not in the special items map, so nothing was given."),
|
|
*Quest.GetName(), *SpecialItem.Key.ToString());
|
|
continue;
|
|
}
|
|
|
|
Inventory->AddItemToInventory(ItemId, FMath::Max(1, SpecialItem.Value));
|
|
}
|
|
}
|
|
|
|
for (const TPair<FGameplayTag, int32>& Friendship : Rewards.Friendship)
|
|
{
|
|
AEleriGameState::ModifyFriendshipAmount(this, Friendship.Key, Friendship.Value);
|
|
}
|
|
|
|
for (const TPair<EExpType, float>& Experience : Rewards.Experience)
|
|
{
|
|
UMainBlueprintFunctionLibrary::ModifyExp(this, Experience.Key, Experience.Value);
|
|
}
|
|
|
|
if (UGameInstance* GameInstance = GetGameInstance())
|
|
{
|
|
if (UGameEventSubsystem* EventSubsystem = GameInstance->GetSubsystem<UGameEventSubsystem>())
|
|
{
|
|
for (const TSoftObjectPtr<UGameEventDataAsset>& Event : Rewards.EventsToStart)
|
|
{
|
|
if (Event.IsNull())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Started through the event subsystem, so an event a quest unlocks is still subject to
|
|
// its own requirements and cannot be forced open by a reward.
|
|
EventSubsystem->StartEvent(Event.ToSoftObjectPath());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
bool UQuestSubsystem::AreRequiredObjectivesComplete(const FQuestRuntimeState& State) const
|
|
{
|
|
const UQuestDataAsset* Quest = State.Quest;
|
|
if (Quest == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (const FQuestObjective& Objective : Quest->Objectives)
|
|
{
|
|
if (Objective.bOptional)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const FQuestObjectiveProgress* Progress = State.FindProgress(Objective.ObjectiveId);
|
|
if (Progress == nullptr || !Progress->bComplete)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return Quest->CountRequiredObjectives() > 0;
|
|
}
|
|
|
|
bool UQuestSubsystem::AdvanceObjective(FQuestRuntimeState& State, FName ObjectiveId, int32 Amount)
|
|
{
|
|
if (Amount <= 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
UQuestDataAsset* Quest = State.Quest;
|
|
if (Quest == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const FQuestObjective* Objective = Quest->FindObjective(ObjectiveId);
|
|
FQuestObjectiveProgress* Progress = State.FindProgress(ObjectiveId);
|
|
if (Objective == nullptr || Progress == nullptr || Progress->bComplete)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// A quest that asks for its steps in order does not hear about the ones still waiting behind the
|
|
// step the player is on, which is what stops a later step from being counted by something done on
|
|
// the way to the earlier one.
|
|
if (!Quest->IsObjectiveUnlocked(Quest->FindObjectiveIndex(ObjectiveId), State))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// The conditions are asked again every time the step is about to count, so a step written as "in
|
|
// the rain" is not counted while the sun is out even if its trigger fires.
|
|
if (!Objective->TestConditions(GetTestContext()))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const int32 Required = FMath::Max(1, Objective->RequiredCount);
|
|
const int32 NewCount = FMath::Min(Required, Progress->CurrentCount + Amount);
|
|
if (NewCount == Progress->CurrentCount)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Progress->CurrentCount = NewCount;
|
|
Progress->bComplete = NewCount >= Required;
|
|
|
|
const bool bNowComplete = Progress->bComplete;
|
|
const FName CountedObjectiveId = Objective->ObjectiveId;
|
|
|
|
OnQuestObjectiveProgress.Broadcast(Quest, CountedObjectiveId, NewCount, Required);
|
|
|
|
if (bNowComplete)
|
|
{
|
|
OnQuestObjectiveCompleted.Broadcast(Quest, CountedObjectiveId);
|
|
}
|
|
|
|
// Both broadcasts and everything they started have been and gone by now, so the state the caller
|
|
// handed in is not touched again: whatever happens next looks the quest up by tag.
|
|
if (bNowComplete)
|
|
{
|
|
UpdateQuestStatuses();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool UQuestSubsystem::RecountInventoryObjectives(FQuestRuntimeState& State)
|
|
{
|
|
UQuestDataAsset* Quest = State.Quest;
|
|
if (Quest == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
UInventoryComponent* Inventory = GetPlayerInventory();
|
|
if (Inventory == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool bChanged = false;
|
|
|
|
for (int32 Index = 0; Index < Quest->Objectives.Num(); ++Index)
|
|
{
|
|
const FQuestObjective& Objective = Quest->Objectives[Index];
|
|
|
|
if (Objective.StepType != EQuestStepType::AcquireItems && Objective.StepType != EQuestStepType::DeliverItems)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const FPrimaryAssetId ItemId = Objective.TargetItem;
|
|
if (!ItemId.IsValid())
|
|
{
|
|
UE_LOG(LogTemp, Warning, TEXT("Quest '%s' step '%s' asks for an item that could not be resolved, so it can never be done."),
|
|
*Quest->GetName(), *Objective.ObjectiveId.ToString());
|
|
continue;
|
|
}
|
|
|
|
// The state is looked up for every step, because broadcasting a step's progress lets a listener
|
|
// start, finish or abandon another quest, which can reallocate the map.
|
|
FQuestRuntimeState* CurrentState = QuestStates.Find(Quest->QuestTag);
|
|
if (CurrentState == nullptr)
|
|
{
|
|
return bChanged;
|
|
}
|
|
|
|
// A step behind another step of a sequential quest is not read at all, so what the player is
|
|
// already carrying only starts counting once the quest has actually asked for it.
|
|
const int32 Held = Quest->IsObjectiveUnlocked(Index, *CurrentState)
|
|
? Inventory->GetStackForItem(ItemId, Objective.bHighQuality)
|
|
: 0;
|
|
|
|
FQuestObjectiveProgress* Progress = CurrentState->FindProgress(Objective.ObjectiveId);
|
|
if (Progress == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const int32 Required = FMath::Max(1, Objective.RequiredCount);
|
|
const int32 NewCount = FMath::Clamp(Held, 0, Required);
|
|
const bool bNowComplete = NewCount >= Required;
|
|
|
|
if (NewCount == Progress->CurrentCount && bNowComplete == Progress->bComplete)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Progress->CurrentCount = NewCount;
|
|
Progress->bComplete = bNowComplete;
|
|
bChanged = true;
|
|
|
|
OnQuestObjectiveProgress.Broadcast(Quest, Objective.ObjectiveId, NewCount, Required);
|
|
|
|
if (bNowComplete)
|
|
{
|
|
OnQuestObjectiveCompleted.Broadcast(Quest, Objective.ObjectiveId);
|
|
}
|
|
}
|
|
|
|
return bChanged;
|
|
}
|
|
|
|
void UQuestSubsystem::UpdateQuestStatuses()
|
|
{
|
|
// A copy of the keys rather than a walk of the map: moving a quest on announces it, and a listener
|
|
// is free to start another quest in response, which can reallocate the map being walked.
|
|
TArray<FGameplayTag> QuestTags;
|
|
QuestStates.GetKeys(QuestTags);
|
|
|
|
for (const FGameplayTag& QuestTag : QuestTags)
|
|
{
|
|
FQuestRuntimeState* State = QuestStates.Find(QuestTag);
|
|
if (State == nullptr || State->Quest == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (State->Status != EQuestStatus::Active && State->Status != EQuestStatus::ReadyToTurnIn)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// The deadline is asked first: a quest that has run out of days is gone even if the step that
|
|
// would have finished it was performed on the way.
|
|
const int32 Deadline = State->Quest->DeadlineInDays;
|
|
if (Deadline > 0 && GetCurrentDay() - State->AcceptedDay > Deadline)
|
|
{
|
|
SetQuestStatus(*State, EQuestStatus::Failed);
|
|
continue;
|
|
}
|
|
|
|
// Ready to hand in only when the steps are done and the conditions for the hand-in still hold.
|
|
// A quest whose conditions lapsed in the meantime drops back to being under way, which is what
|
|
// the turn-in requirements are for.
|
|
const bool bCanHandIn = AreRequiredObjectivesComplete(*State)
|
|
&& State->Quest->AreTurnInRequirementsMet(GetTestContext());
|
|
|
|
SetQuestStatus(*State, bCanHandIn ? EQuestStatus::ReadyToTurnIn : EQuestStatus::Active);
|
|
}
|
|
}
|
|
|
|
void UQuestSubsystem::RefreshActiveQuests()
|
|
{
|
|
TArray<FGameplayTag> QuestTags;
|
|
QuestStates.GetKeys(QuestTags);
|
|
|
|
for (const FGameplayTag& QuestTag : QuestTags)
|
|
{
|
|
FQuestRuntimeState* State = QuestStates.Find(QuestTag);
|
|
if (State == nullptr || State->Quest == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (State->Status != EQuestStatus::Active && State->Status != EQuestStatus::ReadyToTurnIn)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
RecountInventoryObjectives(*State);
|
|
}
|
|
|
|
// Last, because counting an item step can be the thing that finishes a quest: the status of every
|
|
// running quest is settled once everything has had its say.
|
|
UpdateQuestStatuses();
|
|
}
|
|
|
|
void UQuestSubsystem::HandleObjectiveTrigger(EQuestStepType StepType, AActor* Target, FGameplayTag GameplayTag)
|
|
{
|
|
if (QuestStates.Num() == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
TArray<FGameplayTag> QuestTags;
|
|
QuestStates.GetKeys(QuestTags);
|
|
|
|
for (const FGameplayTag& QuestTag : QuestTags)
|
|
{
|
|
FQuestRuntimeState* State = QuestStates.Find(QuestTag);
|
|
if (State == nullptr || State->Quest == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (State->Status != EQuestStatus::Active && State->Status != EQuestStatus::ReadyToTurnIn)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// The asset is kept, the state is not: a step that completes overrides the status of its own
|
|
// quest, and a listener of that can start another quest, which can reallocate the map the state
|
|
// above lives in.
|
|
UQuestDataAsset* Quest = State->Quest;
|
|
|
|
for (const FQuestObjective& Objective : Quest->Objectives)
|
|
{
|
|
if (Objective.StepType != StepType)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bool bMatched = false;
|
|
|
|
if (StepType == EQuestStepType::DialogueEvent)
|
|
{
|
|
// A dialogue event is matched by tag alone: the line fired the event the step named,
|
|
// whoever said it. That is what makes a quest count a conversation it did not start.
|
|
bMatched = GameplayTag.IsValid()
|
|
&& Objective.TargetGameplayTag.IsValid()
|
|
&& GameplayTag.MatchesTag(Objective.TargetGameplayTag);
|
|
}
|
|
else if (StepType == EQuestStepType::TalkToNpc)
|
|
{
|
|
// A talk step is satisfied by holding the conversation with the NPC it names, and also
|
|
// by a line that fired the event it names, which is how a conversation with several
|
|
// outcomes is one step rather than several.
|
|
bMatched = (Target != nullptr && DoesActorMatchObjective(Objective, Target))
|
|
|| (GameplayTag.IsValid()
|
|
&& Objective.TargetGameplayTag.IsValid()
|
|
&& GameplayTag.MatchesTag(Objective.TargetGameplayTag));
|
|
}
|
|
else
|
|
{
|
|
bMatched = Target != nullptr && DoesActorMatchObjective(Objective, Target);
|
|
}
|
|
|
|
if (!bMatched)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (FQuestRuntimeState* CurrentState = QuestStates.Find(QuestTag))
|
|
{
|
|
AdvanceObjective(*CurrentState, Objective.ObjectiveId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
bool UQuestSubsystem::DoesActorMatchObjective(const FQuestObjective& Objective, const AActor* Actor) const
|
|
{
|
|
if (Actor == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// The actor the step names, when it names one. Asked both ways round: a resolved reference is the
|
|
// cheap path, and the path is what a step that points at an actor in a level which is not loaded
|
|
// right now can still be matched against.
|
|
if (Objective.TargetActors.ContainsByPredicate([Actor](const TSoftObjectPtr<AActor>& OtherActor)
|
|
{
|
|
return OtherActor.ToSoftObjectPath() == FSoftObjectPath(Actor);
|
|
}))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// The marker the step names, which is how a LocationArrival step matching a marker that is not the
|
|
// player's own is recognised.
|
|
if (!Objective.TargetLocationMarker.IsNull()
|
|
&& (Objective.TargetLocationMarker.Get() == Actor || Objective.TargetLocationMarker.ToSoftObjectPath() == FSoftObjectPath(Actor)))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// The NPC tag the actor carries, when the step names one
|
|
if (Objective.TargetNpcTag.IsValid())
|
|
{
|
|
const FGameplayTag ActorTag = ResolveActorNpcTag(Actor);
|
|
if (ActorTag.IsValid() && ActorTag.MatchesTag(Objective.TargetNpcTag))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// A step that names nothing is satisfied by anything, which is how "use one of the wells" is
|
|
// written without having to list every well in the level.
|
|
return Objective.TargetActors.IsEmpty()
|
|
&& Objective.TargetLocationMarker.IsNull()
|
|
&& !Objective.TargetNpcTag.IsValid();
|
|
}
|
|
|
|
void UQuestSubsystem::NotifyQuestSignal(const FQuestSignal& Signal, AActor* Source)
|
|
{
|
|
if (!Signal.SignalTag.IsValid())
|
|
{
|
|
return;
|
|
}
|
|
|
|
TArray<FGameplayTag> QuestTags;
|
|
QuestStates.GetKeys(QuestTags);
|
|
|
|
for (const FGameplayTag& QuestTag : QuestTags)
|
|
{
|
|
FQuestRuntimeState* State = QuestStates.Find(QuestTag);
|
|
if (State == nullptr || State->Quest == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (State->Status != EQuestStatus::Active && State->Status != EQuestStatus::ReadyToTurnIn)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
UQuestDataAsset* Quest = State->Quest;
|
|
const FGameplayTag SourceTag = Source != nullptr ? ResolveActorNpcTag(Source) : FGameplayTag();
|
|
|
|
for (const FQuestObjective& Objective : Quest->Objectives)
|
|
{
|
|
if (!Objective.IsCustomStep() || !Objective.CustomSignalTag.IsValid())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!Signal.SignalTag.MatchesTag(Objective.CustomSignalTag))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// A signal may name the one step it is about, which is what a quest with two similar custom
|
|
// steps uses so that the two cannot be counted by the same event.
|
|
if (!Signal.ObjectiveId.IsNone() && Signal.ObjectiveId != Objective.ObjectiveId)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// A step that names a speaker only takes a signal that can be traced to that speaker, and a
|
|
// signal that names one only takes what came from it. Both are the same question asked from
|
|
// either end, and a caller that passes no source at all answers neither.
|
|
if (Objective.TargetNpcTag.IsValid()
|
|
&& (!SourceTag.IsValid() || !SourceTag.MatchesTag(Objective.TargetNpcTag)))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (Signal.SourceNpcTag.IsValid()
|
|
&& (!SourceTag.IsValid() || !SourceTag.MatchesTag(Signal.SourceNpcTag)))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (FQuestRuntimeState* CurrentState = QuestStates.Find(QuestTag))
|
|
{
|
|
AdvanceObjective(*CurrentState, Objective.ObjectiveId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void UQuestSubsystem::NotifyInteraction(AActor* Interactable, AActor* Interactor)
|
|
{
|
|
if (Interactable == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
HandleObjectiveTrigger(EQuestStepType::InteractWith, Interactable, FGameplayTag());
|
|
}
|
|
|
|
void UQuestSubsystem::NotifyDialogueStarted(AActor* Speaker)
|
|
{
|
|
if (Speaker == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
HandleObjectiveTrigger(EQuestStepType::TalkToNpc, Speaker, FGameplayTag());
|
|
}
|
|
|
|
void UQuestSubsystem::NotifyDialogueEvent(FGameplayTag EventTag, AActor* Speaker)
|
|
{
|
|
if (!EventTag.IsValid())
|
|
{
|
|
return;
|
|
}
|
|
|
|
// The event is offered to both kinds of talk step: one written against a conversation and one
|
|
// written against a line that can come out of it both mean "this conversation happened".
|
|
HandleObjectiveTrigger(EQuestStepType::DialogueEvent, Speaker, EventTag);
|
|
HandleObjectiveTrigger(EQuestStepType::TalkToNpc, Speaker, EventTag);
|
|
}
|
|
|
|
void UQuestSubsystem::NotifyInventoryChanged()
|
|
{
|
|
if (bGrantingRewards)
|
|
{
|
|
return;
|
|
}
|
|
|
|
RefreshActiveQuests();
|
|
}
|
|
|
|
void UQuestSubsystem::NotifyLocationReached(AActor* Marker)
|
|
{
|
|
if (Marker == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
HandleObjectiveTrigger(EQuestStepType::LocationArrival, Marker, FGameplayTag());
|
|
}
|
|
|
|
void UQuestSubsystem::HandlePossessedPawnChanged(APawn* OldPawn, APawn* NewPawn)
|
|
{
|
|
// Whatever kind of pawn the player ends up in, what a quest watches is the character they are
|
|
// driving, so the bindings are remade rather than a second pawn being bound alongside the first.
|
|
EnsureGameplayBound(GetBoundWorld());
|
|
}
|
|
|
|
void UQuestSubsystem::HandleDialogueSpeakerChanged(AActor* Speaker)
|
|
{
|
|
// Fired both when a conversation starts and when it ends: the end comes with no speaker, and a
|
|
// conversation nobody is being held with is not something a talk step can count.
|
|
NotifyDialogueStarted(Speaker);
|
|
}
|
|
|
|
void UQuestSubsystem::HandleDialogueGameplayEvent(FGameplayTag GameplayTag)
|
|
{
|
|
// A dialogue event does not name who fired it, so whoever is being talked to at this moment, if
|
|
// anyone, is where it came from.
|
|
AActor* Speaker = nullptr;
|
|
|
|
if (UGameInstance* GameInstance = GetGameInstance())
|
|
{
|
|
if (const UDialogueSubsystem* DialogueSubsystem = GameInstance->GetSubsystem<UDialogueSubsystem>())
|
|
{
|
|
Speaker = DialogueSubsystem->GetActiveSpeaker();
|
|
}
|
|
}
|
|
|
|
NotifyDialogueEvent(GameplayTag, Speaker);
|
|
}
|
|
|
|
void UQuestSubsystem::HandleInventoryUpdated()
|
|
{
|
|
NotifyInventoryChanged();
|
|
}
|
|
|
|
void UQuestSubsystem::HandlePlayerInteract()
|
|
{
|
|
UWorld* World = GetBoundWorld();
|
|
if (World == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// The player character's interaction delegate carries nothing, so the actor is taken from the
|
|
// interaction system, which is the one the player had under the crosshair when they pressed the key.
|
|
// Anything that knows exactly what was used calls NotifyInteraction directly instead.
|
|
AActor* Interactable = UInteractionSubsystem::GetClosestInteractable(World);
|
|
NotifyInteraction(Interactable, BoundCharacter.Get());
|
|
}
|
|
|
|
void UQuestSubsystem::HandleDayPassed()
|
|
{
|
|
// Deadlines are counted in days, and a day only turns over on this one call.
|
|
RefreshActiveQuests();
|
|
}
|
|
|
|
void UQuestSubsystem::PrepareSaveData(TArray<FQuestSaveEntry>& OutEntries) const
|
|
{
|
|
OutEntries.Reset();
|
|
|
|
for (const TPair<FGameplayTag, FQuestRuntimeState>& Pair : QuestStates)
|
|
{
|
|
const FQuestRuntimeState& State = Pair.Value;
|
|
if (State.Quest == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// An unoffered quest is nothing the player has done, so it is not written: whether it can be
|
|
// taken is worked out from its requirements the moment the save is loaded, which is also what
|
|
// keeps a save from freezing a quest into a state its conditions have since moved past.
|
|
if (State.Status == EQuestStatus::Unavailable)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
FQuestSaveEntry Entry;
|
|
Entry.QuestPath = FSoftObjectPath(State.Quest);
|
|
Entry.Status = State.Status;
|
|
Entry.AcceptedDay = State.AcceptedDay;
|
|
Entry.CompletedDay = State.CompletedDay;
|
|
|
|
for (const FQuestObjectiveProgress& Progress : State.Objectives)
|
|
{
|
|
FQuestObjectiveSave ObjectiveSave;
|
|
ObjectiveSave.ObjectiveId = Progress.ObjectiveId;
|
|
ObjectiveSave.CurrentCount = Progress.CurrentCount;
|
|
ObjectiveSave.bComplete = Progress.bComplete;
|
|
|
|
Entry.Objectives.Add(ObjectiveSave);
|
|
}
|
|
|
|
OutEntries.Add(Entry);
|
|
}
|
|
}
|
|
|
|
void UQuestSubsystem::ApplySaveData(const TArray<FQuestSaveEntry>& Entries)
|
|
{
|
|
QuestStates.Reset();
|
|
|
|
for (const FQuestSaveEntry& Entry : Entries)
|
|
{
|
|
// Loaded by path rather than by pointer, and a quest that is no longer in the project is left
|
|
// out with a word in the log: an author who deletes a quest should not be able to break a save
|
|
// that was made while it existed.
|
|
UQuestDataAsset* Quest = Cast<UQuestDataAsset>(Entry.QuestPath.TryLoad());
|
|
if (Quest == nullptr)
|
|
{
|
|
UE_LOG(LogTemp, Warning, TEXT("A quest in this save ('%s') is no longer in the project. Its progress is dropped."),
|
|
*Entry.QuestPath.ToString());
|
|
continue;
|
|
}
|
|
|
|
if (Entry.Status == EQuestStatus::Unavailable || Entry.Status == EQuestStatus::Available)
|
|
{
|
|
// Nothing was accepted, so there is nothing to put back: the offer is a question asked of
|
|
// the requirements, and it is asked again the first time anybody looks.
|
|
continue;
|
|
}
|
|
|
|
FQuestRuntimeState State;
|
|
State.Quest = Quest;
|
|
State.Status = Entry.Status;
|
|
State.AcceptedDay = Entry.AcceptedDay;
|
|
State.CompletedDay = Entry.CompletedDay;
|
|
|
|
for (const FQuestObjectiveSave& ObjectiveSave : Entry.Objectives)
|
|
{
|
|
FQuestObjectiveProgress Progress;
|
|
Progress.ObjectiveId = ObjectiveSave.ObjectiveId;
|
|
Progress.CurrentCount = ObjectiveSave.CurrentCount;
|
|
Progress.bComplete = ObjectiveSave.bComplete;
|
|
|
|
// Filled in from the asset below, so that a step added to a quest after this save was made
|
|
// is not left without a record of its own.
|
|
Progress.bCountedFromInventory = false;
|
|
|
|
State.Objectives.Add(Progress);
|
|
}
|
|
|
|
// A step that is in the asset but not in the save, because it was added after the save was
|
|
// taken, starts where a new quest starts: at zero.
|
|
for (const FQuestObjective& Objective : Quest->Objectives)
|
|
{
|
|
FQuestObjectiveProgress* Progress = State.FindProgress(Objective.ObjectiveId);
|
|
if (Progress == nullptr)
|
|
{
|
|
FQuestObjectiveProgress NewProgress;
|
|
NewProgress.ObjectiveId = Objective.ObjectiveId;
|
|
State.Objectives.Add(NewProgress);
|
|
Progress = &State.Objectives.Last();
|
|
}
|
|
|
|
Progress->bCountedFromInventory = Objective.StepType == EQuestStepType::AcquireItems
|
|
|| Objective.StepType == EQuestStepType::DeliverItems;
|
|
}
|
|
|
|
QuestStates.Add(Quest->QuestTag, State);
|
|
}
|
|
|
|
// The steps counted from the inventory are read again from what the player is actually carrying: a
|
|
// save taken half way through a delivery must not remember an item the player has since sold. A
|
|
// load that happens before the player exists leaves that to the next refresh, which the world
|
|
// binding performs as soon as there is a pawn to read.
|
|
RefreshActiveQuests();
|
|
|
|
UE_LOG(LogTemp, Log, TEXT("Quest subsystem restored %d quests from a save."), QuestStates.Num());
|
|
}
|