remove old quest system and assets, replace with v1 of new quest system

This commit is contained in:
2026-09-28 20:06:13 +02:00
parent 3a3a04b689
commit bbc16b3355
44 changed files with 2957 additions and 606 deletions

View File

@@ -1,14 +0,0 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "NpcDatabase.h"
bool UNpcDatabase::FindNpc(FName NpcID, FNpc& Npc) {
for(int i = 0; i < AllNpcData.Num(); i++) {
if(AllNpcData[i].NpcID == NpcID) {
Npc = AllNpcData[i];
return true;
}
}
return false;
}

View File

@@ -1,35 +0,0 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "NpcDatabase.generated.h"
USTRUCT(BlueprintType)
struct FNpc {
GENERATED_USTRUCT_BODY()
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Npc")
FName NpcID;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Npc")
FName NpcName;
};
/**
*
*/
UCLASS()
class PROJECTELERI_API UNpcDatabase : public UDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Quest")
TArray<FNpc> AllNpcData;
bool FindNpc(FName NpcID, FNpc& Npc);
};

View File

@@ -1,357 +0,0 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "OrderManager.h"
#include "Kismet/GameplayStatics.h"
// Sets default values
AOrderManager::AOrderManager()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
}
void AOrderManager::OnDayPassedResponse() {
UE_LOG(LogTemp, Warning, TEXT("Day passed, checking from Order Manager"));
//Get all orders that can be accepted in this month, increase counter if found
//If the month didn't change do not check it again, simply take cached results
QuestQueue.Empty();
for (int i = 0; i < QuestDatabase->AllQuestsByNpc.Num(); ++i) {
for (int j = 0; j < QuestDatabase->AllQuestsByNpc[i].QuestList.Num(); ++j) {
UE_LOG(LogTemp, Warning, TEXT("Checking quest with ID: %s"), *QuestDatabase->AllQuestsByNpc[i].QuestList[j].QuestID.ToString());
FQuestItem questCompletionInfo;
if(CheckIfQuestComplete(QuestDatabase->AllQuestsByNpc[i].QuestList[j].QuestID,questCompletionInfo)) {
if(QuestDatabase->AllQuestsByNpc[i].QuestList[j].PrerequisiteQuestID.IsNone()
|| !FindQuest(QuestDatabase->AllQuestsByNpc[i].QuestList[j].QuestID,questCompletionInfo)) {
FQuest NextQuest;
if(questCompletionInfo.ResponseIndex == -1) {
QuestDatabase->GetQuestData(questCompletionInfo.QuestID, NextQuest);
}else {
FQuest Current;
QuestDatabase->GetQuestData(questCompletionInfo.QuestID, Current);
QuestDatabase->GetQuestData(Current.ResponsesQuestLinks[questCompletionInfo.ResponseIndex], NextQuest);
}
UE_LOG(LogTemp, Warning, TEXT("Days passed currently: %d"), TimeOfDayActor->GetAllDaysPassed());
UE_LOG(LogTemp, Warning, TEXT("Quest appears on day: %d"), QuestDatabase->AllQuestsByNpc[i].QuestList[j].DaysFromLastQuest);
if(QuestDatabase->AllQuestsByNpc[i].QuestList[j].PrerequisiteQuestID.IsNone()) {
if(TimeOfDayActor->GetAllDaysPassed() >= QuestDatabase->AllQuestsByNpc[i].QuestList[j].DaysFromLastQuest) {
QuestQueue.Add(NextQuest);
}
break;
}else {
int32 passedDays = TimeOfDayActor->GetAllDaysPassed() - questCompletionInfo.QuestCompleteDay;
if(passedDays > 0)
QuestQueue.Add(NextQuest);
break;
}
}
}else {
if(QuestDatabase->AllQuestsByNpc[i].QuestList[j].PrerequisiteQuestID.IsNone()) {
if(TimeOfDayActor->GetAllDaysPassed() >= QuestDatabase->AllQuestsByNpc[i].QuestList[j].DaysFromLastQuest) {
QuestQueue.Add(QuestDatabase->AllQuestsByNpc[i].QuestList[j]);
}
break;
}else {
int32 passedDays = TimeOfDayActor->GetAllDaysPassed() - questCompletionInfo.QuestCompleteDay;
if(passedDays > 0)
QuestQueue.Add(QuestDatabase->AllQuestsByNpc[i].QuestList[j]);
break;
}
}
}
if(QuestQueue.Num() >= 8)
break;
}
UE_LOG(LogTemp, Warning, TEXT("Number of quests in the queue: %d"), QuestQueue.Num());
//Limit orders to 1 per NPC and maximum of 5 NPCs,
//check logs to see how many orders player has left over from last day,
//if they didn't complete anything only add 1/2 new orders, if they did then add up to 5
/*if(QuestQueue.Num() > 0) {
int32 questAddCounter = 1;
if(QuestsCompletedLastDay > 1) {
questAddCounter = 5;
if(questAddCounter > QuestQueue.Num())
questAddCounter = QuestQueue.Num();
}
for (int i = 0; i < questAddCounter; ++i) {
AcceptQuest(QuestQueue[0].QuestID,TimeOfDayActor->GetAllDaysPassed());
QuestQueue.RemoveAt(0);
}
QuestsCompletedLastDay = 2;
}*/
for (int i = 0; i < QuestQueue.Num(); ++i) {
AcceptQuest(QuestQueue[i].QuestID,TimeOfDayActor->GetAllDaysPassed());
}
}
// Called when the game starts or when spawned
void AOrderManager::BeginPlay()
{
Super::BeginPlay();
TimeOfDayActor = Cast<ATimeOfDayManager, AActor>(UGameplayStatics::GetActorOfClass(GetWorld(), ATimeOfDayManager::StaticClass()));
TimeOfDayActor->OnDayPassed.AddDynamic(this,&AOrderManager::OnDayPassedResponse);
}
// Called every frame
void AOrderManager::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
FQuest AOrderManager::FindQuest_Implementation(FName QuestID, bool& sucess) {
sucess = false;
FQuest Quest;
if (QuestDatabase == nullptr) {return Quest;}
for (int i = 0; i < QuestDatabase->AllQuestsByNpc.Num(); i++) {
for (int j = 0; j < QuestDatabase->AllQuestsByNpc[i].QuestList.Num(); ++j) {
if(QuestDatabase->AllQuestsByNpc[i].QuestList[j].QuestID == QuestID) {
sucess = true;
return QuestDatabase->AllQuestsByNpc[i].QuestList[j];
}
}
}
return Quest;
}
TArray<FQuest> AOrderManager::GetAllAcceptedQuests(FName NpcID) {
TArray<FQuest> acceptedQuests;
for (int i = 0; i < QuestList.Num(); i++) {
FQuest Data;
if(QuestDatabase->GetQuestData(QuestList[i].QuestID,Data)
&& Data.NpcID == NpcID) {
FName ID = QuestList[i].QuestID;
FQuest questToAdd;
if(!QuestDatabase->GetQuestData(ID,questToAdd))
continue;
acceptedQuests.Add(questToAdd);
}
}
return acceptedQuests;
}
TArray<FNpcQuest> AOrderManager::GetAllDifferentNpcs() {
TArray<FNpcQuest> allNpcQuests;
for (int k = 0; k < QuestDatabase->AllQuestsByNpc.Num(); ++k) {
TArray<FQuest> acceptedQuests = GetAllAcceptedQuests(QuestDatabase->AllQuestsByNpc[k].NpcID);
for(int i = 0; i < acceptedQuests.Num(); i++) {
int alreadyFound = -1;
for(int j = 0; j < allNpcQuests.Num(); j++) {
if(acceptedQuests[i].NpcID == allNpcQuests[j].NpcID)
alreadyFound = j;
}
if(alreadyFound == -1) {
FNpcQuest questToAdd;
questToAdd.NpcID = acceptedQuests[i].NpcID;
questToAdd.QuestIDList.Add(acceptedQuests[i].QuestID);
allNpcQuests.Add(questToAdd);
}else {
allNpcQuests[alreadyFound].QuestIDList.Add(acceptedQuests[i].QuestID);
}
}
}
UE_LOG(LogTemp, Warning, TEXT("Returning %d quests"), allNpcQuests.Num());
return allNpcQuests;
}
FNpc AOrderManager::GetNpc(FName NpcID) {
FNpc* npcToReturn = new FNpc();
NpcDatabase->FindNpc(NpcID,*npcToReturn);
return *npcToReturn;
}
TArray<FNpcQuest> AOrderManager::GetQuestsByNpc() {
TArray<FNpcQuest> allNpcQuests;
for(int i = 0; i < QuestList.Num(); i++) {
int alreadyFound = -1;
FQuest questToCompare;
for(int j = 0; j < allNpcQuests.Num(); j++) {
if(QuestDatabase->GetQuestData(QuestList[i].QuestID,questToCompare)) {
if(questToCompare.NpcID == allNpcQuests[j].NpcID)
alreadyFound = j;
}
}
if(alreadyFound == -1) {
FNpcQuest questToAdd;
questToAdd.NpcID = questToCompare.NpcID;
questToAdd.QuestIDList.Add(questToCompare.QuestID);
allNpcQuests.Add(questToAdd);
}else {
allNpcQuests[alreadyFound].QuestIDList.Add(questToCompare.QuestID);
}
}
return allNpcQuests;
}
bool AOrderManager::FindQuest(FName QuestID, FQuestItem& Quest) {
for(int i = 0; i < QuestList.Num(); i++) {
if(QuestList[i].QuestID == QuestID) {
Quest = QuestList[i];
return true;
}
}
return false;
}
void AOrderManager::ReadQuest(FName QuestID) {
for (int i = 0; i < QuestList.Num(); ++i) {
if(QuestList[i].QuestID == QuestID) {
QuestList[i].ReadQuest = true;
break;
}
}
}
void AOrderManager::MarkQuestCompleted(FName QuestID) {
for(int i = 0; i < QuestList.Num(); i++) {
if((QuestList[i].QuestID == QuestID) && (!QuestList[i].IsCompleted)) {
QuestList[i].IsCompleted = true;
break;
}
}
//TODO
//Implement update quest list event
}
bool AOrderManager::CheckIfPrerequisiteQuestIsCompleted(FName QuestID) {
int32 indexFound = -1;
for (int i = 0; i < QuestList.Num(); ++i) {
if(QuestList[i].QuestID == QuestID)
indexFound = i;
}
if(QuestList[indexFound].IsCompleted)
return true;
return false;
}
bool AOrderManager::CheckIfQuestComplete(FName QuestID, FQuestItem& QuestItem) {
for (int i = 0; i < QuestList.Num(); ++i) {
if(QuestList[i].QuestID == QuestID) {
QuestItem = QuestList[i];
return QuestList[i].IsCompleted;
}
}
return false;
}
TArray<FQuest> AOrderManager::GetIncompleteQuests() {
TArray<FQuest> incomplete;
for (int i = 0; i < QuestDatabase->AllQuestsByNpc.Num(); ++i) {
for (int j = 0; j < QuestDatabase->AllQuestsByNpc[i].QuestList.Num(); ++j) {
bool found = false;
for (int k = 0; k < QuestList.Num(); ++k) {
if(QuestDatabase->AllQuestsByNpc[i].QuestList[j].QuestID ==
QuestList[j].QuestID && QuestList[j].IsCompleted)
found = true;
}
if(!found)
incomplete.Add(QuestDatabase->AllQuestsByNpc[i].QuestList[j]);
}
}
return incomplete;
}
void AOrderManager::AcceptQuest(FName QuestID, int32 currentDay, int32 response, TArray<FName> itemArray) {
bool QuestFound = false;
for(int i = 0; i < QuestList.Num(); i++) {
if(QuestList[i].QuestID == QuestID) {
QuestFound = true;
break;
}
}
if(!QuestFound) {
FQuestItem NewQuest;
NewQuest.QuestID = QuestID;
NewQuest.IsCompleted = false;
NewQuest.QuestAcceptDay = currentDay;
NewQuest.ResponseIndex = response;
NewQuest.ItemIDsSubmitted = itemArray;
QuestList.Add(NewQuest);
//TODO
//Implement update quest list event
}
}
void AOrderManager::AcceptQuest(FName QuestID, int32 currentDay) {
bool QuestFound = false;
for(int i = 0; i < QuestList.Num(); i++) {
if(QuestList[i].QuestID == QuestID) {
QuestFound = true;
break;
}
}
if(!QuestFound) {
FQuestItem NewQuest;
NewQuest.QuestID = QuestID;
NewQuest.IsCompleted = false;
NewQuest.QuestAcceptDay = currentDay;
NewQuest.QuestCompleteDay = -1;
QuestList.Add(NewQuest);
//TODO
//Implement update quest list event
}
}
bool AOrderManager::SubmitAnswerWithItems(FName QuestID, int32 ResponseIndex, TArray<FName> ItemIndexes) {
FQuest questToLookAt;
for (int i = 0; i < QuestList.Num(); ++i) {
if(QuestList[i].QuestID == QuestID
&& QuestDatabase->GetQuestData(QuestList[i].QuestID,questToLookAt)) {
int itemsToCheck = ItemIndexes.Num();
for (int j = 0; j < ItemIndexes.Num(); ++j) {
for (int k = 0; k < questToLookAt.QuestItemRequest.Num(); ++k) {
if(questToLookAt.QuestItemRequest[k].ItemID == ItemIndexes[j])
itemsToCheck--;
}
}
if(itemsToCheck == 0) {
QuestList[i].ResponseIndex = ResponseIndex;
QuestList[i].ItemIDsSubmitted = ItemIndexes;
QuestList[i].IsCompleted = true;
return true;
}
}
}
return false;
}
void AOrderManager::SubmitAnswer(FName QuestID, int32 ResponseIndex) {
for (int i = 0; i < QuestList.Num(); ++i) {
if(QuestList[i].QuestID == QuestID) {
QuestList[i].ResponseIndex = ResponseIndex;
QuestList[i].IsCompleted = true;
UE_LOG(LogTemp, Warning, TEXT("Quest response recorded fot quest with ID:%s"), *QuestID.ToString());
}
}
}
FQuest AOrderManager::GetQuestInfo(FName QuestID) {
FQuest quest;
QuestDatabase->GetQuestData(QuestID, quest);
return quest;
}
TArray<FQuestItem> AOrderManager::GetAllAcceptedQuestForSaving() {
TArray<FQuestItem> quests;
quests = QuestList;
return quests;
}
void AOrderManager::LoadQuestsIntoMemory(TArray<FQuestItem> quests) {
QuestList = quests;
}
FQuestItem AOrderManager::GetAcceptedQuestInfo(FName QuestID) {
FQuestItem quest;
for (int i = 0; i < QuestList.Num(); ++i) {
if(QuestID == QuestList[i].QuestID) {
quest = QuestList[i];
}
}
return quest;
}

View File

@@ -1,108 +0,0 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "NpcDatabase.h"
#include "QuestDatabase.h"
#include "TimeOfDayManager.h"
#include "QuestDatabase.h"
#include "GameFramework/Actor.h"
#include "OrderManager.generated.h"
UCLASS(Blueprintable)
class PROJECTELERI_API AOrderManager : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
AOrderManager();
//TODO Save variable
UPROPERTY(VisibleAnywhere)
int32 LastCheckedMonth;
UPROPERTY(VisibleAnywhere)
TArray<FQuest> QuestQueue;
UPROPERTY(VisibleAnywhere)
ATimeOfDayManager* TimeOfDayActor;
UPROPERTY(EditDefaultsOnly)
UQuestDatabase* QuestDatabase;
UPROPERTY(EditDefaultsOnly)
UNpcDatabase* NpcDatabase;
UPROPERTY(EditAnywhere)
int32 QuestsCompletedLastDay = 2;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Quest")
TArray<FQuestItem> QuestList;
UFUNCTION()
void OnDayPassedResponse();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category="Quests")
FQuest FindQuest(FName QuestID, bool& sucess);
FQuest FindQuest_Implementation(FName QuestID, bool& sucess);
bool FindQuest(FName QuestID, FQuestItem& Quest);
UFUNCTION(BlueprintCallable)
void ReadQuest(FName QuestID);
UFUNCTION(BlueprintCallable)
void MarkQuestCompleted(FName QuestID);
bool CheckIfPrerequisiteQuestIsCompleted(FName QuestID);
UFUNCTION(BlueprintCallable)
bool CheckIfQuestComplete(FName QuestID, FQuestItem& QuestItem);
TArray<FQuest> GetIncompleteQuests();
void AcceptQuest(FName QuestID, int32 currentDay, int32 response, TArray<FName> itemArray);
void AcceptQuest(FName QuestID, int32 currentDay);
UFUNCTION(BlueprintCallable)
bool SubmitAnswerWithItems(FName QuestID, int32 ResponseIndex, TArray<FName> ItemIndexes);
UFUNCTION(BlueprintCallable)
void SubmitAnswer(FName QuestID, int32 ResponseIndex);
UFUNCTION(BlueprintCallable)
FQuest GetQuestInfo(FName QuestID);
//Helpers for accepted quests
UFUNCTION(BlueprintCallable)
TArray<FQuestItem> GetAllAcceptedQuestForSaving();
UFUNCTION(BlueprintCallable)
void LoadQuestsIntoMemory(TArray<FQuestItem> quests);
UFUNCTION(BlueprintCallable)
FQuestItem GetAcceptedQuestInfo(FName QuestID);
UFUNCTION(BlueprintCallable)
TArray<FQuest> GetAllAcceptedQuests(FName NpcID);
UFUNCTION(BlueprintCallable)
TArray<FNpcQuest> GetAllDifferentNpcs();
UFUNCTION(BlueprintCallable)
FNpc GetNpc(FName NpcID);
//Helpers for unaccepted quests
UFUNCTION(BlueprintCallable)
TArray<FNpcQuest> GetQuestsByNpc();
};

View File

@@ -1 +0,0 @@
#include "PlayerQuest.h"

View File

@@ -116,6 +116,13 @@ struct FQuests {
/**
*
*/
/**
* The original, name based quest asset.
*
* Superseded by UQuestDataAsset, which keys a quest by gameplay tag instead of by FName, holds its steps
* as data rather than as dialogue responses, and is driven by UQuestSubsystem. Left in place because
* QuestData.uasset is content this still reads; a new quest belongs in a UQuestDataAsset.
*/
UCLASS()
class PROJECTELERI_API UQuestDatabase : public UDataAsset
{

View File

@@ -44,9 +44,6 @@ public:
static UItemDatabase* Get(const UObject* WorldContext);
UPROPERTY(EditDefaultsOnly)
UItemContainer* ItemDatabase;
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Alchemy Settings")
TMap<Element, int32> ElementStrength;

View File

@@ -54,6 +54,11 @@ void UEleriGameInstance::SaveGame()
if(!SaveGameData || !PlayerChar || !EleriGameState) return;
// Stamped here rather than in the constructor, because the same class is what a load deserializes
// into: a version set by the constructor would make every save made before the field existed read
// as a current one.
SaveGameData->SaveVersion = UEleriSaveGame::CurrentSaveVersion;
SaveGameData->SaveData = EleriGameState->PrepareSaveData();
FAsyncSaveGameToSlotDelegate SavedDelegate;

View File

@@ -12,6 +12,7 @@
#include "ProjectEleri/GameObjects/EleriBaseActor.h"
#include "ProjectEleri/Ai/NpcScheduleComponent.h"
#include "ProjectEleri/GameObjects/BaseCharacter.h"
#include "ProjectEleri/QuestSystem/QuestSubsystem.h"
namespace
{
@@ -250,6 +251,24 @@ void AEleriGameState::PropagateSaveData(UEleriSaveGame* InSaveData)
}
}
//Quests. Placed after the inventories above, because a step that is counted from the inventory is
//read from it the moment the quest state is put back, and before the seed restore below, which the
//comment on it asks of everything else.
if (UQuestSubsystem* QuestSubsystem = UQuestSubsystem::Get(this))
{
//The version is only reported for now: a save made before any of this existed simply has no
//quest data in it, and an empty list is exactly what a fresh quest state is, so there is
//nothing to migrate yet. The field is what makes the first real change to the quest save
//layout visible instead of silent.
if (InSaveData->SaveVersion < UEleriSaveGame::CurrentSaveVersion)
{
UE_LOG(LogTemp, Log, TEXT("Save was written with version %d, this build reads version %d."),
InSaveData->SaveVersion, UEleriSaveGame::CurrentSaveVersion);
}
QuestSubsystem->ApplySaveData(SaveData.QuestSaveData);
}
//Seed delete and spawn should always go last
PropagateAllSeedData();
@@ -317,6 +336,12 @@ const FSaveData& AEleriGameState::PrepareSaveData()
SaveData.SetPlayerData(GetWorld());
SaveData.SetAbilitySystemData(GetWorld());
//SAVE QUESTS
if (UQuestSubsystem* QuestSubsystem = UQuestSubsystem::Get(this))
{
QuestSubsystem->PrepareSaveData(SaveData.QuestSaveData);
}
return SaveData;
}

View File

@@ -197,10 +197,6 @@ void AEleriPlayerController::SetupInputComponent()
{
PlayerEnhancedInputComponent->BindAction(BookAction, ETriggerEvent::Started, this, &AEleriPlayerController::OnBookToggle);
}
if (OrdersAction)
{
PlayerEnhancedInputComponent->BindAction(OrdersAction, ETriggerEvent::Started, this, &AEleriPlayerController::OnOrdersToggle);
}
if (CalendarAction)
{
PlayerEnhancedInputComponent->BindAction(CalendarAction, ETriggerEvent::Started, this, &AEleriPlayerController::OnCalendarToggle);
@@ -912,21 +908,6 @@ void AEleriPlayerController::OnBookToggle(const FInputActionValue &Value)
OpenBookOfAlchemy(nullptr);
}
void AEleriPlayerController::OnOrdersToggle(const FInputActionValue &Value)
{
if (Paused || IsDialogueOpen() || IsInMenu() || !MoveActorsComp || MoveActorsComp->IsRemovingMode())
return;
if (OrdersOpen)
{
CloseOrdersUI();
}
else
{
}
}
void AEleriPlayerController::OnCalendarToggle(const FInputActionValue &Value)
{
if (Paused || IsDialogueOpen() || (IsInMenu() && !MainGameUI->IsCalendarOpen()) || !MoveActorsComp || MoveActorsComp->IsRemovingMode())
@@ -1090,18 +1071,6 @@ void AEleriPlayerController::DisableBookAnimating()
TickBookTimer = 0.6f;
}
void AEleriPlayerController::CloseOrdersUI()
{
OrdersOpen = false;
OrdersUI->RemoveFromParent();
if (MenuStack.Num() == 0)
{
SetShowMouseCursor(false);
SetInputMode(FInputModeGameOnly());
}
}
UMainMenuWidget *AEleriPlayerController::OpenMainMenu()
{
if (!IsValid(MainGameUI))

View File

@@ -11,11 +11,19 @@
#include "ProjectEleri/Ai/NpcScheduleComponent.h"
#include "ProjectEleri/GameObjects/BaseCharacter.h"
UEleriSaveGame::UEleriSaveGame()
{
// Deliberately not the current version: this runs for a load as well as for a new save, and an old
// file would then look like a new one. The version is stamped by whoever writes the save.
SaveVersion = 0;
}
void FSaveData::Initialize()
{
BookProgression.Empty();
InventoryData.Empty();
QuestList.Empty();
QuestSaveData.Empty();
SeedBedData.Empty();
PlacedActors.Empty();
ActorSaveData.Empty();

View File

@@ -90,11 +90,6 @@ private:
UPROPERTY()
UMainGameWidget* MainGameUI;
UPROPERTY()
bool OrdersOpen;
UPROPERTY()
UUserWidget* OrdersUI;
UPROPERTY()
bool TickBookAnimating = false;
UPROPERTY()
@@ -144,9 +139,6 @@ public:
UPROPERTY(EditDefaultsOnly, Category = "UI")
TSubclassOf<UMainGameWidget> MainGameWidgetBlueprint;
UPROPERTY(EditDefaultsOnly, Category = "UI")
TSubclassOf<UUserWidget> OrdersBlueprint;
UPROPERTY(EditDefaultsOnly, Category = "UI")
TSubclassOf<UEleriBaseWidget> AlchemyUiBlueprint;
@@ -195,8 +187,6 @@ public:
UPROPERTY(EditDefaultsOnly, Category = "UI Input")
UInputAction* BookAction;
UPROPERTY(EditDefaultsOnly, Category = "UI Input")
UInputAction* OrdersAction;
UPROPERTY(EditDefaultsOnly, Category = "UI Input")
UInputAction* CalendarAction;
UPROPERTY(EditDefaultsOnly, Category = "UI Input")
UInputAction* MapAction;
@@ -331,7 +321,6 @@ public:
void OnMainMenuToggle(const FInputActionValue& Value);
void OnInventoryToggle(const FInputActionValue& Value);
void OnBookToggle(const FInputActionValue& Value);
void OnOrdersToggle(const FInputActionValue& Value);
void OnCalendarToggle(const FInputActionValue& Value);
void OnMapToggle(const FInputActionValue& Value);
@@ -356,9 +345,6 @@ public:
UFUNCTION(BlueprintCallable)
void CloseBookOfAlchemy();
UFUNCTION(BlueprintCallable)
void CloseOrdersUI();
UFUNCTION(BlueprintCallable)
UMainMenuWidget* OpenMainMenu();
UFUNCTION(BlueprintCallable)

View File

@@ -10,6 +10,7 @@
#include "../System/Subsystem/ObjectPersistenceSubsystem.h"
#include "Interface/SaveableObjectInterface.h"
#include "ProjectEleri/GameEventSystem/GameEventSubsystem.h"
#include "ProjectEleri/QuestSystem/QuestTypes.h"
#include "Data/GameplayStatEffect.h"
#include "Data/StatForgeDefs.h"
#include "ProjectEleri/Items/SeedBedActor.h"
@@ -56,6 +57,17 @@ struct FSaveData {
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Quest")
TArray<FQuestItem> QuestList;
/**
* The quest system's own state, one entry per quest the player has touched.
*
* Kept next to the legacy list above rather than replacing it, because the order manager is still
* the only thing writing that one and a save that carried both is a save both can read. An empty
* array is what a fresh quest state is, so a save made before any of this existed loads through
* here without needing a migration.
*/
UPROPERTY()
TArray<FQuestSaveEntry> QuestSaveData;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Seeds")
TMap<int32, FSeedBedSaveData> SeedBedData;
@@ -106,8 +118,24 @@ class PROJECTELERI_API UEleriSaveGame : public USaveGame
GENERATED_BODY()
public:
UEleriSaveGame();
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FSaveData SaveData;
/**
* Which layout this file was written with.
*
* Zero, the default, is what a save made before this field existed deserializes to: an absent
* property is left at the value the constructor gave it, which is why the stamp is applied when a
* save is written rather than being set in the constructor. Without that, every old save would read
* as current and there would be no way to tell them apart.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Version")
int32 SaveVersion = 0;
/** The layout this build writes. Raise it when something is added to FSaveData that an older save
* cannot be read through, and handle the difference where that field is read back. */
static constexpr int32 CurrentSaveVersion = 1;
};

View File

@@ -0,0 +1,174 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "QuestDataAsset.h"
#include "ProjectEleri/GameEventSystem/GameEventRequirements.h"
#include "ProjectEleri/Items/DataAssets/ItemDataAsset.h"
namespace
{
/** Every entry has to pass. An entry that is not a requirement at all is skipped rather than
* failing the quest, so one unreadable condition cannot take a whole quest off the board. */
bool AreRequirementsMet(const TArray<FInstancedStruct>& Requirements, const UObject* Context)
{
for (const FInstancedStruct& Requirement : Requirements)
{
const FGameEventRequirement* Typed = Requirement.GetPtr<FGameEventRequirement>();
if (Typed == nullptr)
{
continue;
}
if (!Typed->Test(Context))
{
return false;
}
}
return true;
}
}
bool UQuestDataAsset::AreOfferRequirementsMet(const UObject* TestContext) const
{
return AreRequirementsMet(OfferRequirements, TestContext);
}
bool UQuestDataAsset::AreTurnInRequirementsMet(const UObject* TestContext) const
{
return AreRequirementsMet(TurnInRequirements, TestContext);
}
int32 UQuestDataAsset::FindObjectiveIndex(FName ObjectiveId) const
{
if (ObjectiveId.IsNone())
{
return INDEX_NONE;
}
for (int32 Index = 0; Index < Objectives.Num(); ++Index)
{
if (Objectives[Index].ObjectiveId == ObjectiveId)
{
return Index;
}
}
return INDEX_NONE;
}
const FQuestObjective* UQuestDataAsset::FindObjective(FName ObjectiveId) const
{
const int32 Index = FindObjectiveIndex(ObjectiveId);
return Objectives.IsValidIndex(Index) ? &Objectives[Index] : nullptr;
}
bool UQuestDataAsset::HasDeliverStep() const
{
return Objectives.ContainsByPredicate(
[](const FQuestObjective& Objective) { return Objective.StepType == EQuestStepType::DeliverItems; });
}
int32 UQuestDataAsset::CountRequiredObjectives() const
{
int32 Count = 0;
for (const FQuestObjective& Objective : Objectives)
{
if (!Objective.bOptional)
{
++Count;
}
}
return Count;
}
bool UQuestDataAsset::IsObjectiveUnlocked(int32 Index, const FQuestRuntimeState& State) const
{
if (!bSequentialObjectives || Index <= 0)
{
return true;
}
// Everything before it has to be done, optional steps included: "in order" means the step above is
// finished, and an optional step that is still open is one the player has not done yet.
if (!Objectives.IsValidIndex(Index))
{
return false;
}
for (int32 Earlier = 0; Earlier < Index; ++Earlier)
{
const FQuestObjectiveProgress* Progress = State.FindProgress(Objectives[Earlier].ObjectiveId);
if (Progress == nullptr || !Progress->bComplete)
{
return false;
}
}
return true;
}
void UQuestDataAsset::CollectDeliverables(const FQuestRuntimeState& State, TArray<TPair<FPrimaryAssetId, int32>>& OutItems) const
{
for (const FQuestObjective& Objective : Objectives)
{
if (Objective.StepType != EQuestStepType::DeliverItems || Objective.bOptional)
{
continue;
}
const FQuestObjectiveProgress* Progress = State.FindProgress(Objective.ObjectiveId);
if (Progress == nullptr || !Progress->bComplete)
{
continue;
}
const FPrimaryAssetId ItemId = Objective.TargetItem.LoadSynchronous()
? Objective.TargetItem->GetPrimaryAssetId()
: FPrimaryAssetId();
if (!ItemId.IsValid())
{
continue;
}
OutItems.Emplace(ItemId, FMath::Max(1, Objective.RequiredCount));
}
}
bool UQuestDataAsset::IsValidQuest(FString& OutError) const
{
if (!QuestTag.IsValid())
{
OutError = TEXT("no quest tag");
return false;
}
if (Objectives.Num() == 0)
{
OutError = TEXT("no objectives");
return false;
}
TSet<FName> SeenIds;
for (const FQuestObjective& Objective : Objectives)
{
if (Objective.ObjectiveId.IsNone())
{
OutError = TEXT("an objective has no id");
return false;
}
if (SeenIds.Contains(Objective.ObjectiveId))
{
OutError = FString::Printf(TEXT("objective id '%s' is used twice"), *Objective.ObjectiveId.ToString());
return false;
}
SeenIds.Add(Objective.ObjectiveId);
}
return true;
}

View File

@@ -0,0 +1,120 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "GameplayTagContainer.h"
#include "QuestTypes.h"
#include "QuestDataAsset.generated.h"
class UTexture2D;
/**
* One quest, authored as an asset.
*
* A quest is a tag, a list of steps and a list of things that have to be true before it can be taken.
* Everything else about it, the journal entry, the rewards and the conditions on individual steps, hangs
* off those three, so a quest with no code of its own is a data asset drop and one line of dialogue.
*
* Steps are not a state machine. Each one listens to whatever the game already broadcasts for it, so an
* InteractWith step is satisfied by the same interaction the player performs to open a chest, and two
* quests watching the same NPC both count the same conversation without either of them owning it.
*/
UCLASS(BlueprintType, PrioritizeCategories = ("Quest", "Objectives", "Requirements", "Rewards"))
class PROJECTELERI_API UQuestDataAsset : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
/** Identity of the quest. Every lookup, every requirement and every save entry goes through this
* tag, so it is the one field that has to be filled in. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Quest", meta = (GameplayTagFilter = "Quests"))
FGameplayTag QuestTag;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Quest")
FText QuestName;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Quest", meta = (MultiLine = true))
FText QuestDescription;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Quest")
TSoftObjectPtr<UTexture2D> QuestIcon;
/** The NPC this quest is handed out by. The journal uses it to say who to go back to, and a
* dialogue can ask "do you have anything for me" through it. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Quest", meta = (ForceInlineRow, Categories = "NpcTags"))
FGameplayTag QuestGiverTag;
/** Days the player has to hand the quest in. 0 means it never runs out. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Quest", meta = (ClampMin = 0))
int32 DeadlineInDays = 0;
/** The steps, in the order the journal lists them. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Objectives", meta = (TitleProperty = "Description"))
TArray<FQuestObjective> Objectives;
/** When set, a step starts listening only once the step before it is done. Off by default, so a
* quest that asks for three separate things reads as one list of three rather than as a chain. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Objectives")
bool bSequentialObjectives = false;
/** The items a DeliverItems step asks for are taken out of the inventory at the moment the quest
* is handed in, which is what makes a delivery a delivery. Turn it off for a quest that only
* wants to see the item and give it back. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Objectives")
bool bConsumeDeliveredItems = true;
/** Everything that has to be true before this quest can be offered. A prerequisite quest is one of
* these rather than a field of its own: "the fisherman's request", Completed. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Requirements", meta = (ExcludeBaseStruct, BaseStruct = "/Script/ProjectEleri.GameEventRequirement"))
TArray<FInstancedStruct> OfferRequirements;
/** Checked a second time when the quest is handed in, for a quest whose conditions can lapse
* between finishing the last step and getting back to the giver. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Requirements", meta = (ExcludeBaseStruct, BaseStruct = "/Script/ProjectEleri.GameEventRequirement"))
TArray<FInstancedStruct> TurnInRequirements;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Rewards")
FQuestRewardBundle Rewards;
/**
* True when everything the quest asks for before it can be taken holds right now.
*
* TestContext is the player character, the same context the event system hands its requirements, so
* a condition written for one system behaves identically in the other.
*/
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Quest")
bool AreOfferRequirementsMet(const UObject* TestContext) const;
/** Checked when the quest is handed in rather than when it is offered */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Quest")
bool AreTurnInRequirementsMet(const UObject* TestContext) const;
/** The step with this id, or null. Objective ids are unique inside one quest. */
const FQuestObjective* FindObjective(FName ObjectiveId) const;
/** The step with this id and where it sits in Objectives, or INDEX_NONE */
int32 FindObjectiveIndex(FName ObjectiveId) const;
/** True when the quest asks the player to hand anything over */
bool HasDeliverStep() const;
/** Every step that has to be done for the quest to be handed in. Hidden and optional steps are
* left out, since neither of them can hold a quest back. */
int32 CountRequiredObjectives() const;
/** True when the step at Index may run: everything before it is done when the quest asks for its
* steps in order, and it is free to run when it does not. */
bool IsObjectiveUnlocked(int32 Index, const FQuestRuntimeState& State) const;
/** The items every finished DeliverItems step is asking for, resolved to what the inventory files
* them under. Empty when there is nothing to hand over. */
void CollectDeliverables(const FQuestRuntimeState& State, TArray<TPair<FPrimaryAssetId, int32>>& OutItems) const;
/** Says whether the asset is worth offering at all: a quest with no tag, no steps or a duplicate
* step id would otherwise fail quietly in the middle of a playthrough. */
bool IsValidQuest(FString& OutError) const;
};

View File

@@ -0,0 +1,134 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "QuestLocationMarker.h"
#include "Components/SphereComponent.h"
#include "GameFramework/Character.h"
#include "Kismet/GameplayStatics.h"
#include "TimerManager.h"
#include "QuestSubsystem.h"
AQuestLocationMarker::AQuestLocationMarker()
{
// Nothing here needs a tick: the overlap is an event and the fallback is a timer, both of which cost
// nothing while nothing is happening.
PrimaryActorTick.bCanEverTick = false;
ArrivalSphere = CreateDefaultSubobject<USphereComponent>(TEXT("ArrivalSphere"));
SetRootComponent(ArrivalSphere);
ArrivalSphere->SetSphereRadius(ArrivalRadius);
ArrivalSphere->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
ArrivalSphere->SetCollisionResponseToAllChannels(ECR_Ignore);
ArrivalSphere->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap);
ArrivalSphere->SetGenerateOverlapEvents(true);
ArrivalSphere->SetHiddenInGame(true);
}
void AQuestLocationMarker::OnConstruction(const FTransform& Transform)
{
Super::OnConstruction(Transform);
// The authored radius is the radius the sphere has, so the volume drawn in the level is the volume
// the game tests against.
if (ArrivalSphere != nullptr)
{
ArrivalSphere->SetSphereRadius(FMath::Max(1.f, ArrivalRadius), false);
}
}
void AQuestLocationMarker::BeginPlay()
{
Super::BeginPlay();
if (ArrivalSphere != nullptr)
{
ArrivalSphere->OnComponentBeginOverlap.AddDynamic(this, &AQuestLocationMarker::HandleBeginOverlap);
ArrivalSphere->OnComponentEndOverlap.AddDynamic(this, &AQuestLocationMarker::HandleEndOverlap);
}
if (UWorld* World = GetWorld())
{
World->GetTimerManager().SetTimer(
CheckTimerHandle, this, &AQuestLocationMarker::CheckPlayerDistance, FMath::Max(0.1f, CheckInterval), true);
}
// Asked once now rather than waiting for the first interval, because a player who is already
// standing on the marker when it spawns has not arrived anywhere, they are simply there.
CheckPlayerDistance();
}
void AQuestLocationMarker::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(CheckTimerHandle);
}
Super::EndPlay(EndPlayReason);
}
void AQuestLocationMarker::HandleBeginOverlap(
UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex,
bool bFromSweep, const FHitResult& SweepResult)
{
CheckPlayerDistance();
}
void AQuestLocationMarker::HandleEndOverlap(
UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
CheckPlayerDistance();
}
void AQuestLocationMarker::RefreshArrival()
{
CheckPlayerDistance();
}
void AQuestLocationMarker::CheckPlayerDistance()
{
AActor* Player = FindPlayerActor();
const float RadiusSquared = FMath::Square(FMath::Max(1.f, ArrivalRadius));
const bool bInside = Player != nullptr
&& FVector::DistSquared(Player->GetActorLocation(), GetActorLocation()) <= RadiusSquared;
if (bInside == bPlayerInside)
{
return;
}
bPlayerInside = bInside;
// Only the way in counts. Standing on a marker is one arrival however often it is noticed, and a
// step that wants the player to come back has a count of its own to say so.
if (bPlayerInside)
{
if (UQuestSubsystem* QuestSubsystem = UQuestSubsystem::Get(this))
{
QuestSubsystem->NotifyLocationReached(this);
}
}
}
AActor* AQuestLocationMarker::FindPlayerActor()
{
if (CachedPlayer.IsValid())
{
return CachedPlayer.Get();
}
const UWorld* World = GetWorld();
if (World == nullptr)
{
return nullptr;
}
AActor* Player = UGameplayStatics::GetPlayerCharacter(World, 0);
CachedPlayer = Player;
return Player;
}

View File

@@ -0,0 +1,81 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "QuestLocationMarker.generated.h"
class USphereComponent;
/**
* A spot in the level a quest step can ask the player to stand on.
*
* This is the one piece of world furniture the quest system needs, and it exists because arrival is the
* one thing the game does not already announce: there is no existing signal for "the player has come
* near this place", so there has to be something in the level that can say it.
*
* The sphere is the arrival test, and it is resized to the radius the marker is authored with, so what
* a designer sees in the level is exactly what the game acts on. The sphere alone is not enough: a
* player who is already standing there when the level loads, or who arrives by a warp, crosses no
* boundary that generates an overlap, so the marker also measures the distance on a slow repeating
* check. Both paths run through the same one test, which means a single arrival is always announced
* once and never twice.
*/
UCLASS(Blueprintable, BlueprintType)
class PROJECTELERI_API AQuestLocationMarker : public AActor
{
GENERATED_BODY()
public:
AQuestLocationMarker();
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
virtual void OnConstruction(const FTransform& Transform) override;
/** How close the player has to come for the arrival to count. The sphere is resized to match. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Quest", meta = (ClampMin = "1.0", UIMin = "1.0"))
float ArrivalRadius = 400.f;
/** How often the distance is measured as a fallback to the overlap. Half a second, which is fast
* enough that walking in and out again between two checks is the only way to miss an arrival, and
* cheap enough that a level full of markers costs nothing. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Quest", meta = (ClampMin = "0.1", UIMax = "2.0"))
float CheckInterval = 0.5f;
/** True while the player stands inside the marker. An arrival is announced on the way in only. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Quest")
bool IsPlayerInside() const { return bPlayerInside; }
/** Re-runs the arrival test now, for a caller that has just moved the player and does not want to
* wait for the next check. */
UFUNCTION(BlueprintCallable, Category = "Quest")
void RefreshArrival();
protected:
UFUNCTION()
void HandleBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
UFUNCTION()
void HandleEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex);
/** Measures where the player is against this marker and tells the quest subsystem when that has
* changed from outside to inside */
void CheckPlayerDistance();
AActor* FindPlayerActor();
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Quest")
TObjectPtr<USphereComponent> ArrivalSphere;
/** The player pawn, cached because this is asked for twice a second per marker */
TWeakObjectPtr<AActor> CachedPlayer;
FTimerHandle CheckTimerHandle;
bool bPlayerInside = false;
};

View File

@@ -0,0 +1,56 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "QuestRequirements.h"
#include "QuestSubsystem.h"
int32 FGameEventRequirement_QuestStatus::GetStatusRank(EQuestStatus Status)
{
switch (Status)
{
case EQuestStatus::Unavailable:
return 0;
case EQuestStatus::Available:
return 1;
case EQuestStatus::Active:
return 2;
case EQuestStatus::ReadyToTurnIn:
return 3;
case EQuestStatus::Completed:
return 4;
default:
// Failed and Abandoned. A quest in either of those states is not under way any more, so it
// ranks with one nobody has taken rather than satisfying a requirement for a live quest.
return 1;
}
}
bool FGameEventRequirement_QuestStatus::Test(const UObject* TestContext) const
{
if (!QuestTag.IsValid())
{
return false;
}
const UQuestSubsystem* QuestSubsystem = UQuestSubsystem::Get(TestContext);
if (QuestSubsystem == nullptr)
{
return false;
}
const EQuestStatus Status = QuestSubsystem->GetQuestStatus(QuestTag);
// Failed and Abandoned are the two ends a quest can come to rest on, so asking about them is
// asking about that exact outcome rather than about how far along the quest is.
const bool bMatchesExactly = !bAtLeast
|| RequiredStatus == EQuestStatus::Failed
|| RequiredStatus == EQuestStatus::Abandoned;
if (bMatchesExactly)
{
return Status == RequiredStatus;
}
return GetStatusRank(Status) >= GetStatusRank(RequiredStatus);
}

View File

@@ -0,0 +1,53 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "GameplayTagContainer.h"
#include "ProjectEleri/GameEventSystem/GameEventRequirements.h"
#include "QuestTypes.h"
#include "QuestRequirements.generated.h"
/**
* Passes when a quest stands where the author asked for.
*
* This is the only requirement the quest system adds, and it is added to the event system's family
* rather than to a family of its own so that a quest can be offered on the strength of an event having
* run, a friendship having been earned and a quest having been handed in, all in one list.
*
* It also gives quest chains their prerequisites without a field for them: "the fisherman's request",
* Completed, is a prerequisite, and it reads as one.
*/
USTRUCT(BlueprintType)
struct PROJECTELERI_API FGameEventRequirement_QuestStatus : public FGameEventRequirement
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, meta = (ForceInlineRow, GameplayTagFilter = "Quests"))
FGameplayTag QuestTag;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, meta = (ForceInlineRow))
EQuestStatus RequiredStatus = EQuestStatus::Completed;
/**
* When set, a status counts as reached as soon as the quest is at or past RequiredStatus on the way
* a quest travels: Available, Active, ReadyToTurnIn, Completed.
*
* Both of the ways a quest can stop without being finished sit outside that ladder on purpose and
* are always matched exactly, so a failed quest does not satisfy a requirement for one that is
* under way. Leaving this off matches the one status asked for, which is what a requirement written
* against Failed or Abandoned wants.
*/
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, meta = (ForceInlineRow))
bool bAtLeast = true;
virtual bool Test(const UObject* TestContext = nullptr) const override;
/** Where a status sits on the ladder bAtLeast walks. An abandoned or failed quest ranks as one
* that was never taken, which is where it leaves the player. */
static int32 GetStatusRank(EQuestStatus Status);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,330 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "Engine/WorldInitializationValues.h"
#include "GameplayTagContainer.h"
#include "QuestTypes.h"
#include "QuestSubsystem.generated.h"
class AActor;
class AMyCharacter;
class APlayerController;
class ATimeOfDayManager;
class UInventoryComponent;
class UQuestDataAsset;
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnQuestAssetsLoaded);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnQuestStatusChanged, UQuestDataAsset*, Quest, EQuestStatus, NewStatus);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_FourParams(FOnQuestObjectiveProgress, UQuestDataAsset*, Quest, FName, ObjectiveId, int32, CurrentCount, int32, RequiredCount);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnQuestObjectiveCompleted, UQuestDataAsset*, Quest, FName, ObjectiveId);
/**
* Every quest in the project, and the state of the ones the player has touched.
*
* A game instance subsystem rather than a world one, for the same reason the event and dialogue
* subsystems are: a quest outlives the level it was accepted in, and travelling must not lose it.
*
* Nothing here polls. Each step is satisfied by whatever already announces that kind of thing, so an
* InteractWith step costs one delegate binding rather than an overlap test every frame:
*
* - TalkToNpc and DialogueEvent come from UDialogueSubsystem, which is already told who the player is
* talking to and which gameplay events a line fires.
* - InteractWith comes from the player character's own OnInteract, plus NotifyInteraction for callers
* that know exactly what was used.
* - AcquireItems and DeliverItems come from the player's inventory component, which already
* broadcasts every change to it.
* - LocationArrival comes from AQuestLocationMarker, which is the one thing that has to be placed in
* the level to make a "go there" step work.
* - Custom comes from NotifyQuestSignal.
*
* The bindings are re-made whenever the world or the possessed pawn changes, which is what keeps all
* of this working across a level transition, a respawn and a save load.
*/
UCLASS()
class PROJECTELERI_API UQuestSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
// ----- Lifetime ---------------------------------------------------------------
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
/** The subsystem of the game instance the context belongs to, or null when there is none. */
static UQuestSubsystem* Get(const UObject* WorldContext);
// ----- Delegates --------------------------------------------------------------
/** Fired once the project's quest assets have been read, which is asynchronous because it goes
* through the asset manager the way the event and dialogue subsystems do. */
UPROPERTY(BlueprintAssignable, Category = "Eleri|Quest")
FOnQuestAssetsLoaded OnQuestAssetsLoaded;
UPROPERTY(BlueprintAssignable, Category = "Eleri|Quest")
FOnQuestStatusChanged OnQuestStatusChanged;
/** Fired whenever a step's count moves, including while a step is being counted up in place */
UPROPERTY(BlueprintAssignable, Category = "Eleri|Quest")
FOnQuestObjectiveProgress OnQuestObjectiveProgress;
UPROPERTY(BlueprintAssignable, Category = "Eleri|Quest")
FOnQuestObjectiveCompleted OnQuestObjectiveCompleted;
// ----- Registry ---------------------------------------------------------------
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Quest")
bool QuestsLoaded() const { return bQuestsLoaded; }
/** Every quest asset in the project, in no particular order */
const TArray<TObjectPtr<UQuestDataAsset>>& GetAllQuests() const { return QuestAssets; }
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Quest")
UQuestDataAsset* GetQuestByTag(FGameplayTag QuestTag) const;
// ----- Status -----------------------------------------------------------------
/**
* Where the quest stands.
*
* A quest nobody has accepted is Available when its offer requirements hold and Unavailable when
* they do not, so a quest that becomes takeable the moment the player's friendship is high enough
* is available without anything having to announce it.
*/
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Quest")
EQuestStatus GetQuestStatus(FGameplayTag QuestTag) const;
/** True while the quest is being worked on or waiting to be handed in */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Quest")
bool IsQuestInProgress(FGameplayTag QuestTag) const;
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Quest")
bool CanAcceptQuest(FGameplayTag QuestTag) const;
/** How far one step has got, 0 when nothing of that quest is running */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Quest")
int32 GetObjectiveProgress(FGameplayTag QuestTag, FName ObjectiveId) const;
// ----- Journal ----------------------------------------------------------------
/** Every quest the player has taken and not finished, ready to draw */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
TArray<FQuestJournalEntry> GetTrackedQuestEntries() const;
/** Everything currently on offer, which is what a giver's "anything for me?" asks */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
TArray<FQuestJournalEntry> GetOfferableQuestEntries() const;
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
TArray<FQuestJournalEntry> GetQuestEntries(EQuestStatus Status) const;
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
FQuestJournalEntry BuildJournalEntry(const UQuestDataAsset* Quest) const;
// ----- What the player does ---------------------------------------------------
/** Puts a quest on offer once its requirements hold. Returns false when they do not. */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
bool OfferQuest(FGameplayTag QuestTag);
/** Accepts a quest. Its offer requirements are checked, so a quest can be started without being
* offered first and a dialogue node does not have to care which of the two it calls. */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
bool StartQuest(FGameplayTag QuestTag);
/** Hands the quest in: takes the delivery items, grants the rewards and records the day. Only
* works once every required step is done. */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
bool CompleteQuest(FGameplayTag QuestTag);
/** Drops the quest, keeping how far it had got in case it is taken up again */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
bool AbandonQuest(FGameplayTag QuestTag);
/** Fails the quest outright, for a deadline that ran out or a story beat that moved on */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
bool FailQuest(FGameplayTag QuestTag);
/**
* Drives every Custom step waiting on the signal's tag from a blueprint.
*
* Source is optional and only narrows a signal down: a step that names a speaker ignores signals
* from anybody else, which is how "the blacksmith told you the rumour" is authored without the
* caller having to know which quest cares.
*/
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
void NotifyQuestSignal(const FQuestSignal& Signal, AActor* Source);
// ----- What the rest of the game tells the quest system -----------------------
//
// The same entry points the bindings use, left public so a blueprint that already knows exactly
// what happened does not have to go looking for the actor an event would only have implied.
/** Something was used. Interactable is the actor the player acted on. */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
void NotifyInteraction(AActor* Interactable, AActor* Interactor = nullptr);
/** A conversation began, or moved to another speaker */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
void NotifyDialogueStarted(AActor* Speaker);
/** A line fired one of the dialogue gameplay events */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
void NotifyDialogueEvent(FGameplayTag EventTag, AActor* Speaker);
/** Something in the player's inventory changed. Called by the inventory binding. */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
void NotifyInventoryChanged();
/** The player arrived at a quest marker. Called by AQuestLocationMarker. */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
void NotifyLocationReached(AActor* Marker);
/** Re-reads every running quest: the steps counted from the inventory, the steps a sequential
* quest has just unlocked, and the deadlines. Called on a day change and after a load. */
UFUNCTION(BlueprintCallable, Category = "Eleri|Quest")
void RefreshActiveQuests();
// ----- Saving -----------------------------------------------------------------
/** Everything the player has done to the quests, ready to be written into the save */
void PrepareSaveData(TArray<FQuestSaveEntry>& OutEntries) const;
/** Puts back what PrepareSaveData wrote. Works through the quest assets that are in the project
* now, so a quest that has been deleted since the save was made is skipped instead of taking the
* whole save down with it. */
void ApplySaveData(const TArray<FQuestSaveEntry>& Entries);
protected:
// ----- Bindings ---------------------------------------------------------------
void HandlePostWorldInitialization(UWorld* World, const FWorldInitializationValues IVS);
void HandleWorldBeginPlay();
void HandleWorldCleanup(UWorld* World, bool bSessionEnded, bool bCleanupResources);
/** Makes sure everything this subsystem listens to points at the world the player is in now.
* Cheap to call: it compares what is bound against what is alive and rebinds only on a change. */
void EnsureGameplayBound(UWorld* World);
UFUNCTION()
void HandlePossessedPawnChanged(APawn* OldPawn, APawn* NewPawn);
UFUNCTION()
void HandleDialogueSpeakerChanged(AActor* Speaker);
UFUNCTION()
void HandleDialogueGameplayEvent(FGameplayTag GameplayTag);
UFUNCTION()
void HandleInventoryUpdated();
UFUNCTION()
void HandlePlayerInteract();
UFUNCTION()
void HandleDayPassed();
/** Lets go of every delegate this subsystem holds, dynamic and otherwise */
void ReleaseGameplayBindings();
// ----- Assets -----------------------------------------------------------------
void QuestAssetsLoaded();
// ----- Progress ---------------------------------------------------------------
/** The record for a quest, created on the spot when there is not one yet */
FQuestRuntimeState& FindOrAddState(FGameplayTag QuestTag);
/** Moves a quest to a new status and announces it. Does nothing when it is already there. */
void SetQuestStatus(FQuestRuntimeState& State, EQuestStatus NewStatus);
/** Counts a step up, announcing it and completing it when it reaches what it asked for. Returns
* true when something changed. */
bool AdvanceObjective(FQuestRuntimeState& State, FName ObjectiveId, int32 Amount = 1);
/** Meters the steps whose progress is a property of the world rather than a tally: how many of
* an item the player is carrying. Returns true when something changed. */
bool RecountInventoryObjectives(FQuestRuntimeState& State);
/** Offers a trigger to every running quest, which counts the steps that match it */
void HandleObjectiveTrigger(EQuestStepType StepType, AActor* Target, FGameplayTag GameplayTag);
/** True when the actor is what the step is waiting for, by way of the NPC tag it carries or the
* actor the step names */
bool DoesActorMatchObjective(const FQuestObjective& Objective, const AActor* Actor) const;
/** Marks every running quest that can be handed in, and fails the ones out of days */
void UpdateQuestStatuses();
/** Hands the rewards over, takes the delivered items and records the day */
void CompleteQuestInternal(FQuestRuntimeState& State);
/** Gives the player everything the bundle names, through the systems that already own each part:
* the inventory, the game state's friendship, the exp library and the event subsystem. */
void GrantRewards(const UQuestDataAsset& Quest);
/** True when every step that cannot be skipped is done. A quest with nothing but optional steps
* counts as done the moment it is accepted, which is what it was written to say. */
bool AreRequiredObjectivesComplete(const FQuestRuntimeState& State) const;
/** The world every trigger is bound in, or null when the subsystem is between worlds */
UWorld* GetBoundWorld() const { return BoundWorld.Get(); }
/** The player character requirements are tested against, the same context the event system hands
* its own requirements */
UObject* GetTestContext() const;
/** The player's inventory, or null before there is a pawn */
UInventoryComponent* GetPlayerInventory() const;
/** Days passed on the game's own clock, 0 when there is no clock in the world */
int32 GetCurrentDay();
ATimeOfDayManager* GetTimeOfDayManager();
/** The NPC tag an actor carries on its schedule component, invalid when it has none */
static FGameplayTag ResolveActorNpcTag(const AActor* Actor);
/** Quest assets, filled once the asset manager has finished reading them */
UPROPERTY()
TArray<TObjectPtr<UQuestDataAsset>> QuestAssets;
/** Where every quest the player has touched stands, keyed by its tag */
UPROPERTY()
TMap<FGameplayTag, FQuestRuntimeState> QuestStates;
UPROPERTY()
bool bQuestsLoaded = false;
private:
/** The world the bindings below point into. Weak, because this subsystem outlives it. */
TWeakObjectPtr<UWorld> BoundWorld;
TWeakObjectPtr<APlayerController> BoundPlayerController;
TWeakObjectPtr<AMyCharacter> BoundCharacter;
TWeakObjectPtr<UInventoryComponent> BoundInventory;
TWeakObjectPtr<ATimeOfDayManager> CachedTimeOfDayManager;
/** Held so the world-level bindings can be taken apart one at a time */
FDelegateHandle WorldBeginPlayHandle;
FDelegateHandle PostWorldInitializationHandle;
FDelegateHandle WorldCleanupHandle;
/** The paths the registry sweep asked the streamable manager for, read back when it is done */
TArray<FSoftObjectPath> AssetPathsToLoad;
/**
* True while a reward is being handed over. The inventory tells the subsystem about every change to
* it, and a reward can be the very item a quest in flight is asking for: without this a reward would
* be able to finish the quest it was paid for.
*/
bool bGrantingRewards = false;
};

View File

@@ -0,0 +1,49 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "QuestTypes.h"
#include "ProjectEleri/GameEventSystem/GameEventRequirements.h"
#include "ProjectEleri/Items/DataAssets/ItemDataAsset.h"
bool FQuestObjective::TestConditions(const UObject* TestContext) const
{
for (const FInstancedStruct& Condition : Conditions)
{
// GetPtr rather than Get, because a condition that is not a requirement at all is skipped
// here instead of tripping the check inside Get: a step with one unreadable entry still runs,
// which is the difference between a quest with a hole in it and a quest that cannot be played.
const FGameEventRequirement* Requirement = Condition.GetPtr<FGameEventRequirement>();
if (Requirement == nullptr)
{
continue;
}
if (!Requirement->Test(TestContext))
{
return false;
}
}
return true;
}
FPrimaryAssetId FQuestItemReward::GetItemId() const
{
if (const UItemDataAsset* ItemAsset = Item.LoadSynchronous())
{
return ItemAsset->GetPrimaryAssetId();
}
return FPrimaryAssetId();
}
FText FQuestItemReward::GetDisplayName() const
{
if (const UItemDataAsset* ItemAsset = Item.LoadSynchronous())
{
return ItemAsset->ItemName;
}
return FText::GetEmpty();
}

View File

@@ -0,0 +1,412 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "StructUtils/InstancedStruct.h"
#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h"
#include "QuestTypes.generated.h"
class AActor;
class UGameEventDataAsset;
class UItemDataAsset;
class UQuestDataAsset;
/**
* Where a quest stands.
*
* Unavailable and Available are not progress: they are what the subsystem answers for a quest nobody
* has accepted yet, Unavailable being the one whose offer requirements do not hold. The remaining five
* are what a save stores, which is why Abandoned is a status of its own rather than the quest simply
* disappearing. A quest the player walked away from is one they can still pick back up.
*/
UENUM(BlueprintType)
enum class EQuestStatus : uint8
{
/** Offer requirements do not hold (yet), so the quest is not the player's to take */
Unavailable UMETA(DisplayName = "Unavailable"),
/** Offer requirements hold and nobody has taken it */
Available,
/** Taken and being worked on */
Active,
/** Every required step is done, waiting to be handed in */
ReadyToTurnIn UMETA(DisplayName = "Ready To Turn In"),
/** Handed in, rewards granted */
Completed,
/** Ran out of time */
Failed,
/** Deliberately dropped by the player */
Abandoned
};
/**
* What has to happen for one step of a quest to be satisfied.
*
* Every type but Custom is watched by the subsystem itself, by binding to whatever already broadcasts
* the event: the dialogue subsystem for TalkToNpc and DialogueEvent, the player's own interaction
* delegate for InteractWith, the inventory for AcquireItems and DeliverItems, and the quest location
* markers for LocationArrival. Custom covers everything the game does not already announce, and is
* driven from a blueprint through NotifyQuestSignal.
*/
UENUM(BlueprintType)
enum class EQuestStepType : uint8
{
/** Counted by NotifyQuestSignal carrying CustomSignalTag */
Custom,
/** Talk to the NPC carrying TargetNpcTag, or start a dialogue with the actor in TargetActor */
TalkToNpc UMETA(DisplayName = "Talk To Npc"),
/** A line fired TargetGameplayTag, whoever said it */
DialogueEvent,
/** Interact with the NPC carrying TargetNpcTag, or with the actor in TargetActor */
InteractWith,
/** Come within ArrivalRadius of the marker in TargetLocationMarker */
LocationArrival UMETA(DisplayName = "Location Arrival"),
/** Carry RequiredCount of TargetItem. Counted from the inventory, so it never over-counts. */
AcquireItems UMETA(DisplayName = "Acquire Items"),
/** Carry RequiredCount of TargetItem and hand it over when the quest is turned in */
DeliverItems UMETA(DisplayName = "Deliver Items")
};
/**
* What a blueprint is allowed to tell the quest subsystem, so a step the game has no event for can
* still be driven from one.
*
* One signal satisfies every active Custom step whose CustomSignalTag matches it, so two quests that
* both wait for "the bell rang" need no wiring at all. ObjectiveId, when set, narrows the signal down
* to that one step, which is what a quest with two similar custom steps wants. SourceNpcTag, when set,
* narrows it to a speaker.
*/
USTRUCT(BlueprintType)
struct PROJECTELERI_API FQuestSignal
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Signal", meta = (Categories = "Quests.Signals"))
FGameplayTag SignalTag;
/** Optional. Empty means the signal counts for every step waiting on SignalTag. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Signal")
FName ObjectiveId;
/** Optional. When set, a signal from any other NPC is ignored. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Signal", meta = (Categories = "NpcTags"))
FGameplayTag SourceNpcTag;
};
/**
* One step of a quest, as authored.
*
* Which fields matter depends on StepType, and the panel hides the others so a step reads as the one
* thing it is: a talk step has a speaker, an item step has an item, and neither shows the other's
* fields. The conditions are checked every time the step is about to count, which is what makes a step
* like "bring the herbs, but not before the rain stops" expressible with no new code at all.
*/
USTRUCT(BlueprintType)
struct PROJECTELERI_API FQuestObjective
{
GENERATED_BODY()
/** Stable id. Referenced by signals, by the journal and by the save, so renaming one loses the
* progress of every player part way through the step. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step")
FName ObjectiveId;
/** Shown in the journal */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step", meta = (MultiLine = true))
FText Description;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step")
EQuestStepType StepType = EQuestStepType::Custom;
/** How many times the step has to happen. Ignored by AcquireItems and DeliverItems, which count
* what the inventory holds rather than how often something occurred. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step", meta = (ClampMin = 1, EditCondition = "StepType!=EQuestStepType::AcquireItems && StepType!=EQuestStepType::DeliverItems"))
int32 RequiredCount = 1;
/** Counted, but not required: the quest can be handed in without it */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step")
bool bOptional = false;
/** Tracked but never listed in the journal */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step")
bool bHidden = false;
/** TalkToNpc / InteractWith: the NPC tag that has to be talked to or used */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step", meta = (Categories = "NpcTags", EditCondition = "StepType==EQuestStepType::TalkToNpc || StepType==EQuestStepType::InteractWith", EditConditionHides))
FGameplayTag TargetNpcTag;
/** TalkToNpc / InteractWith / LocationArrival: the target, when it is not an NPC */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step", meta = (EditCondition = "StepType==EQuestStepType::TalkToNpc || StepType==EQuestStepType::InteractWith || StepType==EQuestStepType::LocationArrival", EditConditionHides))
TSoftObjectPtr<AActor> TargetActor;
/** DialogueEvent: the event a line has to fire. TalkToNpc accepts it too, which is how a
* conversation with several outcomes is made to count. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step", meta = (Categories = "Dialogue.Event", EditCondition = "StepType==EQuestStepType::DialogueEvent || StepType==EQuestStepType::TalkToNpc", EditConditionHides))
FGameplayTag TargetGameplayTag;
/** LocationArrival: the marker to stand near. Its own radius is used unless ArrivalRadius
* overrides it. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step", meta = (EditCondition = "StepType==EQuestStepType::LocationArrival", EditConditionHides))
TSoftObjectPtr<AActor> TargetLocationMarker;
/** LocationArrival: how close counts as arrived. 0 takes the marker's own radius. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step", meta = (ClampMin = 0.f, EditCondition = "StepType==EQuestStepType::LocationArrival", EditConditionHides))
float ArrivalRadius = 0.f;
/** AcquireItems / DeliverItems: the item that has to be carried */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step", meta = (EditCondition = "StepType==EQuestStepType::AcquireItems || StepType==EQuestStepType::DeliverItems", EditConditionHides))
TSoftObjectPtr<UItemDataAsset> TargetItem;
/** AcquireItems / DeliverItems: count only the high quality stack */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step", meta = (EditCondition = "StepType==EQuestStepType::AcquireItems || StepType==EQuestStepType::DeliverItems", EditConditionHides))
bool bHighQuality = false;
/** Custom: the signal this step waits for */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step", meta = (Categories = "Quests.Signals", EditCondition = "StepType==EQuestStepType::Custom", EditConditionHides))
FGameplayTag CustomSignalTag;
/** Checked every time the step is about to count. The same types the event system uses, so a
* quest condition is written exactly the way an event condition is. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Step", meta = (ExcludeBaseStruct, BaseStruct = "/Script/ProjectEleri.GameEventRequirement"))
TArray<FInstancedStruct> Conditions;
/** True when the step could count right now */
bool TestConditions(const UObject* TestContext) const;
/** True for the step types nothing but a signal can satisfy */
bool IsCustomStep() const { return StepType == EQuestStepType::Custom; }
};
/** How far one step of a running quest has got */
USTRUCT(BlueprintType)
struct PROJECTELERI_API FQuestObjectiveProgress
{
GENERATED_BODY()
/** Matches FQuestObjective::ObjectiveId */
UPROPERTY(BlueprintReadOnly, Category = "Step")
FName ObjectiveId;
UPROPERTY(BlueprintReadOnly, Category = "Step")
int32 CurrentCount = 0;
UPROPERTY(BlueprintReadOnly, Category = "Step")
bool bComplete = false;
/** Set for the AcquireItems and DeliverItems steps, which are recomputed from the inventory
* rather than counted up as things happen. */
UPROPERTY(BlueprintReadOnly, Category = "Step")
bool bCountedFromInventory = false;
};
/**
* A quest as the game is playing it.
*
* The data asset is a hard pointer because a running quest is meaningless without one: an asset that
* has left the project takes its quest out of the world with it, which is exactly what the content
* change that removed it asked for. The save stores the path instead, so an old save still loads.
*/
USTRUCT(BlueprintType)
struct PROJECTELERI_API FQuestRuntimeState
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Quest")
TObjectPtr<UQuestDataAsset> Quest = nullptr;
UPROPERTY(BlueprintReadOnly, Category = "Quest")
EQuestStatus Status = EQuestStatus::Unavailable;
/** Day the quest was accepted, on the clock's own count of days */
UPROPERTY(BlueprintReadOnly, Category = "Quest")
int32 AcceptedDay = 0;
/** Day the quest was handed in, 0 while it has not been */
UPROPERTY(BlueprintReadOnly, Category = "Quest")
int32 CompletedDay = 0;
UPROPERTY(BlueprintReadOnly, Category = "Quest")
TArray<FQuestObjectiveProgress> Objectives;
FQuestObjectiveProgress* FindProgress(FName ObjectiveId)
{
return Objectives.FindByPredicate(
[ObjectiveId](const FQuestObjectiveProgress& Entry) { return Entry.ObjectiveId == ObjectiveId; });
}
const FQuestObjectiveProgress* FindProgress(FName ObjectiveId) const
{
return Objectives.FindByPredicate(
[ObjectiveId](const FQuestObjectiveProgress& Entry) { return Entry.ObjectiveId == ObjectiveId; });
}
};
/** One step of a running quest, as written to disk */
USTRUCT()
struct PROJECTELERI_API FQuestObjectiveSave
{
GENERATED_BODY()
UPROPERTY()
FName ObjectiveId;
UPROPERTY()
int32 CurrentCount = 0;
UPROPERTY()
bool bComplete = false;
};
/**
* One quest as written to disk.
*
* The asset is a soft path rather than a pointer so that a save made while a quest existed still loads
* after that quest has been deleted: the entry is skipped with a warning instead of the whole save
* being rejected, which is the only way an author can take a quest back out of a live project.
*/
USTRUCT()
struct PROJECTELERI_API FQuestSaveEntry
{
GENERATED_BODY()
UPROPERTY()
FSoftObjectPath QuestPath;
UPROPERTY()
EQuestStatus Status = EQuestStatus::Unavailable;
UPROPERTY()
int32 AcceptedDay = 0;
UPROPERTY()
int32 CompletedDay = 0;
UPROPERTY()
TArray<FQuestObjectiveSave> Objectives;
};
/** One item handed over when a quest is turned in */
USTRUCT(BlueprintType)
struct PROJECTELERI_API FQuestItemReward
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Item")
TSoftObjectPtr<UItemDataAsset> Item;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Item", meta = (ClampMin = 1))
int32 Amount = 1;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Item")
bool bHighQuality = false;
/** The id the inventory files this item under, or an invalid id when Item is unset. Loading the
* asset is what produces it, and the item database has every item in memory by the time a quest
* can be turned in, so this is a lookup rather than a load in practice. */
FPrimaryAssetId GetItemId() const;
/** Human readable name of the item, for the reward line of a journal entry */
FText GetDisplayName() const;
};
/**
* Everything a quest gives the player when it is handed in.
*
* Gold and the crystals are items too, but they are addressed by tag the way the rest of the game
* addresses them, through UEleriGameSettings::SpecialItemsMap. Experience goes through the same
* ModifyExp the rest of the game uses, and events are started through the event subsystem, so a quest
* reward has no path of its own into any of those systems.
*/
USTRUCT(BlueprintType)
struct PROJECTELERI_API FQuestRewardBundle
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Items")
TArray<FQuestItemReward> Items;
/** Tagged items: Items.Gold, Items.Crystals.* and anything else in the special items map */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Items", meta = (ForceInlineRow, Categories = "Items"))
TMap<FGameplayTag, int32> SpecialItems;
/** Friendship with the NPCs the quest touched */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Rewards", meta = (ForceInlineRow, Categories = "Characters"))
TMap<FGameplayTag, int32> Friendship;
/** Profession experience, by the same types the exp system uses */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Rewards", meta = (ForceInlineRow))
TMap<EExpType, float> Experience;
/** Events this unlocks. Started through the event subsystem, so its own requirements still apply. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Rewards")
TArray<TSoftObjectPtr<UGameEventDataAsset>> EventsToStart;
};
/** One step of a quest as a widget reads it, with everything resolved and nothing to look up */
USTRUCT(BlueprintType)
struct PROJECTELERI_API FQuestObjectiveView
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Journal")
FName ObjectiveId;
UPROPERTY(BlueprintReadOnly, Category = "Journal")
FText Description;
UPROPERTY(BlueprintReadOnly, Category = "Journal")
int32 CurrentCount = 0;
UPROPERTY(BlueprintReadOnly, Category = "Journal")
int32 RequiredCount = 1;
UPROPERTY(BlueprintReadOnly, Category = "Journal")
bool bComplete = false;
UPROPERTY(BlueprintReadOnly, Category = "Journal")
bool bOptional = false;
};
/** One quest of the journal, ready to draw */
USTRUCT(BlueprintType)
struct PROJECTELERI_API FQuestJournalEntry
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Journal")
TObjectPtr<UQuestDataAsset> Quest = nullptr;
UPROPERTY(BlueprintReadOnly, Category = "Journal")
FGameplayTag QuestTag;
UPROPERTY(BlueprintReadOnly, Category = "Journal")
FText QuestName;
UPROPERTY(BlueprintReadOnly, Category = "Journal")
FText QuestDescription;
UPROPERTY(BlueprintReadOnly, Category = "Journal")
EQuestStatus Status = EQuestStatus::Unavailable;
/** True when every required step is done and only the hand-in is left */
UPROPERTY(BlueprintReadOnly, Category = "Journal")
bool bReadyToTurnIn = false;
/** The steps worth showing, in the order the asset lists them */
UPROPERTY(BlueprintReadOnly, Category = "Journal")
TArray<FQuestObjectiveView> Objectives;
};

View File

@@ -10,6 +10,7 @@ class UStatForgeComponent;
class URetainerBox;
class UMainGameWidget;
class UInventoryComponent;
class UQuestSubsystem;
class ARemovableStaticMeshActor;
class UEleriAbilitySystemComponent;
@@ -38,6 +39,9 @@ public:
UFUNCTION(BlueprintCallable, Category = "Eleri|HTTP")
static void PostFeedback(const FString& Message);
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Quest", meta = (WorldContext = "WorldContextObject"))
static UQuestSubsystem* GetQuestSubsystem(const UObject* WorldContextObject);
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri", meta = (WorldContext = "WorldContextObject"))
static UInventoryComponent* GetPlayerInventory(const UObject* WorldContextObject);

View File

@@ -15,6 +15,7 @@
#include "ProjectEleri/GameObjects/BaseCharacter.h"
#include "Runtime/Online/HTTP/Public/HttpModule.h"
#include "ProjectEleri/Settings/EleriGameSettings.h"
#include "ProjectEleri/QuestSystem/QuestSubsystem.h"
void UMainBlueprintFunctionLibrary::PostFeedback(const FString& Message) {
auto request = FHttpModule::Get().CreateRequest();
@@ -41,6 +42,13 @@ UInventoryComponent* UMainBlueprintFunctionLibrary::GetPlayerInventory(const UOb
return nullptr;
}
UQuestSubsystem* UMainBlueprintFunctionLibrary::GetQuestSubsystem(const UObject* WorldContextObject)
{
// Everything a quest can be asked is a function on the subsystem, so this accessor is all a
// blueprint needs: start one, hand one in, read the journal, or fire a signal a custom step waits on.
return UQuestSubsystem::Get(WorldContextObject);
}
UMainGameWidget* UMainBlueprintFunctionLibrary::GetMainGameWidget(UObject* WorldContextObject)
{
if (!WorldContextObject || !WorldContextObject->GetWorld())