From bbc16b33557c4abc015583e88b1d0e9e2b7d1ba9 Mon Sep 17 00:00:00 2001 From: "adnan.mujkic" Date: Mon, 28 Sep 2026 20:06:13 +0200 Subject: [PATCH] remove old quest system and assets, replace with v1 of new quest system --- Config/DefaultGameplayTags.ini | 2 + Content/GameData/ItemData.uasset | 3 - Content/GameData/NpcData.uasset | 3 - Content/GameData/QuestData.uasset | 3 - .../Quests/DA_Quest_Trish_WarmWelcome.uasset | 3 + .../SeedInfoDialogue.uasset | 3 - .../SeedInfoDialogue.uasset.private.0 | Bin 6023 -> 0 bytes .../Quests_Orders/OrderManagerBP.uasset | 3 - .../DialogueSystem/UI/ItemInResponse.uasset | 3 - .../DialogueSystem/UI/QuestingMessage.uasset | 3 - .../DialogueSystem/UI/QuestingNpcTab.uasset | 3 - .../UI/QuestingSingleLine.uasset | 3 - .../DialogueSystem/UI/QuestingWindow.uasset | 3 - .../SelectableDialogueTextButtonStyle.uasset | 3 - .../InventorySystem/ItemManagerBP.uasset | 4 +- .../Blueprints/BP_AnimNotifyFootsteps.uasset | 4 +- .../Blueprints/BP_ThirdPersonCharacter.uasset | 4 +- .../Blueprints/MainPlayerController.uasset | 4 +- .../DialogueSystem/NpcDatabase.cpp | 14 - .../ProjectEleri/DialogueSystem/NpcDatabase.h | 35 - .../DialogueSystem/OrderManager.cpp | 357 ---- .../DialogueSystem/OrderManager.h | 108 -- .../DialogueSystem/PlayerQuest.cpp | 1 - .../ProjectEleri/DialogueSystem/PlayerQuest.h | 1 - .../DialogueSystem/QuestDatabase.h | 7 + Source/ProjectEleri/Items/ItemDatabase.h | 3 - .../Private/EleriGameInstance.cpp | 5 + .../ProjectEleri/Private/EleriGameState.cpp | 25 + .../Private/EleriPlayerController.cpp | 31 - Source/ProjectEleri/Private/EleriSaveGame.cpp | 8 + .../Public/EleriPlayerController.h | 14 - Source/ProjectEleri/Public/EleriSaveGame.h | 30 +- .../QuestSystem/QuestDataAsset.cpp | 174 ++ .../ProjectEleri/QuestSystem/QuestDataAsset.h | 120 ++ .../QuestSystem/QuestLocationMarker.cpp | 134 ++ .../QuestSystem/QuestLocationMarker.h | 81 + .../QuestSystem/QuestRequirements.cpp | 56 + .../QuestSystem/QuestRequirements.h | 53 + .../QuestSystem/QuestSubsystem.cpp | 1449 +++++++++++++++++ .../ProjectEleri/QuestSystem/QuestSubsystem.h | 330 ++++ .../ProjectEleri/QuestSystem/QuestTypes.cpp | 49 + Source/ProjectEleri/QuestSystem/QuestTypes.h | 412 +++++ .../System/MainBlueprintFunctionLibrary.h | 4 + .../System/MainlBlueprintFunctionLibrary.cpp | 8 + 44 files changed, 2957 insertions(+), 606 deletions(-) delete mode 100644 Content/GameData/ItemData.uasset delete mode 100644 Content/GameData/NpcData.uasset delete mode 100644 Content/GameData/QuestData.uasset create mode 100644 Content/GameData/Quests/DA_Quest_Trish_WarmWelcome.uasset delete mode 100644 Content/Scripts/DialogueSystem/GameSpecificDialogue/SeedInfoDialogue.uasset delete mode 100644 Content/Scripts/DialogueSystem/GameSpecificDialogue/SeedInfoDialogue.uasset.private.0 delete mode 100644 Content/Scripts/DialogueSystem/Quests_Orders/OrderManagerBP.uasset delete mode 100644 Content/Scripts/DialogueSystem/UI/ItemInResponse.uasset delete mode 100644 Content/Scripts/DialogueSystem/UI/QuestingMessage.uasset delete mode 100644 Content/Scripts/DialogueSystem/UI/QuestingNpcTab.uasset delete mode 100644 Content/Scripts/DialogueSystem/UI/QuestingSingleLine.uasset delete mode 100644 Content/Scripts/DialogueSystem/UI/QuestingWindow.uasset delete mode 100644 Content/Scripts/DialogueSystem/UI/SelectableDialogueTextButtonStyle.uasset delete mode 100644 Source/ProjectEleri/DialogueSystem/NpcDatabase.cpp delete mode 100644 Source/ProjectEleri/DialogueSystem/NpcDatabase.h delete mode 100644 Source/ProjectEleri/DialogueSystem/OrderManager.cpp delete mode 100644 Source/ProjectEleri/DialogueSystem/OrderManager.h delete mode 100644 Source/ProjectEleri/DialogueSystem/PlayerQuest.cpp delete mode 100644 Source/ProjectEleri/DialogueSystem/PlayerQuest.h create mode 100644 Source/ProjectEleri/QuestSystem/QuestDataAsset.cpp create mode 100644 Source/ProjectEleri/QuestSystem/QuestDataAsset.h create mode 100644 Source/ProjectEleri/QuestSystem/QuestLocationMarker.cpp create mode 100644 Source/ProjectEleri/QuestSystem/QuestLocationMarker.h create mode 100644 Source/ProjectEleri/QuestSystem/QuestRequirements.cpp create mode 100644 Source/ProjectEleri/QuestSystem/QuestRequirements.h create mode 100644 Source/ProjectEleri/QuestSystem/QuestSubsystem.cpp create mode 100644 Source/ProjectEleri/QuestSystem/QuestSubsystem.h create mode 100644 Source/ProjectEleri/QuestSystem/QuestTypes.cpp create mode 100644 Source/ProjectEleri/QuestSystem/QuestTypes.h diff --git a/Config/DefaultGameplayTags.ini b/Config/DefaultGameplayTags.ini index c1292343..c30919f0 100644 --- a/Config/DefaultGameplayTags.ini +++ b/Config/DefaultGameplayTags.ini @@ -56,6 +56,8 @@ NetIndexFirstBitSegment=16 +GameplayTagList=(Tag="SaveGameTags.InteractionStage",DevComment="") +GameplayTagList=(Tag="SaveGameTags.Locked",DevComment="") +GameplayTagList=(Tag="SetByCaller.Effects.ExpModifier",DevComment="") ++GameplayTagList=(Tag="Quests",DevComment="Identity of a quest. Each quest data asset is tagged with Quests., and quests reference each other by these tags.") ++GameplayTagList=(Tag="Quests.Signals",DevComment="Signals a quest step waits on that no system in the game announces, fired from blueprint with NotifyQuestSignal.") +GameplayTagList=(Tag="Skins",DevComment="") +GameplayTagList=(Tag="Skins.Eleri.Body",DevComment="") +GameplayTagList=(Tag="Skins.Eleri.Default",DevComment="") diff --git a/Content/GameData/ItemData.uasset b/Content/GameData/ItemData.uasset deleted file mode 100644 index 53229f2d..00000000 --- a/Content/GameData/ItemData.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:08e5a4550345d3897e9c13ec5e6da652a2a567e786c06191469ab27e7a4547db -size 29671 diff --git a/Content/GameData/NpcData.uasset b/Content/GameData/NpcData.uasset deleted file mode 100644 index 9e1105a5..00000000 --- a/Content/GameData/NpcData.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2f54a946b1e98012d2d904f9982fd0189041ece8daed3383e4bf8f82696309bb -size 1938 diff --git a/Content/GameData/QuestData.uasset b/Content/GameData/QuestData.uasset deleted file mode 100644 index 1a9c7822..00000000 --- a/Content/GameData/QuestData.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a2361d3caaef57b7dc804afea736f9aea19d5a897c0e41e467d1c2b90ab8a3e8 -size 19784 diff --git a/Content/GameData/Quests/DA_Quest_Trish_WarmWelcome.uasset b/Content/GameData/Quests/DA_Quest_Trish_WarmWelcome.uasset new file mode 100644 index 00000000..cf7beadc --- /dev/null +++ b/Content/GameData/Quests/DA_Quest_Trish_WarmWelcome.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e535ee0645cc4a3e8f1dd5534dca2fbfafc6aea7f2a2327a4c92f0cf2b257ed4 +size 1372 diff --git a/Content/Scripts/DialogueSystem/GameSpecificDialogue/SeedInfoDialogue.uasset b/Content/Scripts/DialogueSystem/GameSpecificDialogue/SeedInfoDialogue.uasset deleted file mode 100644 index 943f9bbd..00000000 --- a/Content/Scripts/DialogueSystem/GameSpecificDialogue/SeedInfoDialogue.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:74fe9e8773fc917421d22d0128e824c71b53ec7baca48da8d4df1ee2776e7f3f -size 6696 diff --git a/Content/Scripts/DialogueSystem/GameSpecificDialogue/SeedInfoDialogue.uasset.private.0 b/Content/Scripts/DialogueSystem/GameSpecificDialogue/SeedInfoDialogue.uasset.private.0 deleted file mode 100644 index 475d27e009db7469862793e664b6e3d9b4ae7c4e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6023 zcmb_geQ;FO6+eLlf)EUfl0wlCkQhkF2O;5GlI$jggk&Kfl~NjBHaE%Y?!L`?ZxcdM zgI|MK>!3_a$AU^r`A2K0bg-h+#_9}?qjc;{r_V-1A=FLkkXkIWRD=bv)4xD~*I|D1oYjIlLOnz3pd2vol#nFQCNm~wd9|J$+K!HS>N#p3!tLKT; zqlF6_+=UA_3ct`ykyE&+pvYz*y1VM{BQfwa8=m`Q_owJQ&Kf*4!@;R1n|uKKFJnY* zg%%LGbzZ|4vdmnUPxI?-VNus@S|VW6>O#WnYxQ{*DYs6DmdaqOt`Kzg+)IC~zb>K{ z#mV0HkJG#1u2I#j6G2M^y`t1ZU$n29h26>><(B%xB4qf2R)wL3+UZRH4%hS;a+z+3 zhT7(Pgx8{rl{u?#jG?)MZ9a~TN7p=3i2zg*8-j+={O%UYPW(|a{9a-hTDQm0L&C7S zsj~MYAEY#w`2Bj9i+yML^dJrVd+I-D0bMQHG;ene+pu2ugpD>)cdzho6s;yb`Qmq0 zAc7+*)AVJFQ_^BF6(T4M%@QqTe$6x~>zPmvG>FWo2>V*-`)&eNh91;0-NA5xvR5}1W(-GJzPv_sfd$6UjeL4? zQ|JC<=5)6>ZcfyXZh_C_xX#-y0$Nj50lm_jCAdyc#;}uTI?HW2l&}RRE%)o16*1K1!+*VsNEqU@N0aZa zhnwxGw!;oS_)aHMkv%B$Tx#bdOO?T>q(grwT8F5u)=X=#<|a+KZQSqSj%toolak(j z>KV*luZ1FdEZMtrC#NV{FD%WaSsI<{Ir=o*TEizl!u8hG_TWJ zt(d5L;Q9XNvGqF3P&=g+ea1YS)iT1~Xn5q&Pp-fsgZ2Go_2QSojPq36s!kUrt!-KZ zbVIW++K>}H>?M!vNR<@L$|5 zPIp9}|1i(IufT)E4;X2!3oHjm2{0Ou(tk7_Bq+d0Jm-0>0*57zXMx0{k!8%NJY4SB zFm$b0Im)i1@hJa{#)I4dP&`UMBEW2UKaQnF!vkqh+F41B?$Qgj&#H{0BMt=^sj*1mE6W25#pZ~AeJ)(E6 z!#ap{tdDbnn8v#31BZR780M}Gpn>OeFfcgS_$THfKSU}aJ4?l7?0UA@L>bPQGHhBO zEC(4hzKF6hp88E5cT0q(@ed-gi8kV7JOZ$S+WVprW>f(aM*h6DWg(+hxeG zT0xH?^@Yx;$dZF;nWI+Gco7)0*pI8wo60inn2WulYpzv==*7tEL~F!MLgdwO(aE?p)X(xt^|7@PNO^?QAF-NCnD+Y+ z%4NV{qR$NZwQe}mCL$@?N|gJxHq$y%82RPr0wu zF2-j64{@Jh|6XKe&`XwM2b(~XBI7jKtDt*GCqe7@Sw;csq*m(S90P2aYN(mpW@=}w z$up+NSsQp1Vm@HQ%-c%Vm{dojudl-dsY4@qrB?A6Wrwf3UnRMgZ4l(u5;%i6wv1vD zB__JoREeo(%UJfjFx68F`B*}bB_+^o$+4F0*UWbGk=y3&U=K9Yc2d%}QaN?;EchbI z8}-BR)TB6#uFcyiQjVK7uvmXYMThP05Nnh`W6_nZqL!H~CB{cu0;Nh?Ek{;#uMIp3 zu~iUd9n7I3I*V-tEm~;-V<*bF(Uq=k$4U-l3wzg5*|E6qQ3fCN3|dZ`X$#-Z8GMsD zBTx$Leg-Y(7PLY+ULwau_E^n~%DIna-N#7)3EIg~WqKgQpOu1^$}V_$i)5ELDsp{R zPPByLt_@kTRi&VPAL9K1kZwQm#Yh73*h<--C|eW+040g-?cA($Y&2hcvhA1cGTGXU zib)}VE{Vvd!@@TtR!vav0mZ{|GZN|mkV9kMr}Jzik&>gfu}b3Ono$1>Z*22;-#}pk zz!wQX#5nvV?k-i`E_Y}VqePfcdf-(Rpez<4!n~+U0l0MsgQ~ie#(1{`-0n2-&yIIr z`qyh+J?mfFyZJHmv;A)><-oZ}vd@+6QrY6V1E@vp0rXm7_lVZ`cjQbf`n2@Gz?YTt zwr$UvFY%tY(q`;xJW~D04{t8r@WiSkv5SpVKc%M4qbp3DDP<^EVHi6fwb^3H6K6EO z+Xj^L;*EBjoTm0OrqTAKeLrPj_5yS$-@AD%S=Z<-O|(B483a+ZXM diff --git a/Content/Scripts/DialogueSystem/Quests_Orders/OrderManagerBP.uasset b/Content/Scripts/DialogueSystem/Quests_Orders/OrderManagerBP.uasset deleted file mode 100644 index 7dcc5e6f..00000000 --- a/Content/Scripts/DialogueSystem/Quests_Orders/OrderManagerBP.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:28b83c04bf9f0a325dcbffb5dfb267243276d7f1b7840053bf91a81236de5e89 -size 25024 diff --git a/Content/Scripts/DialogueSystem/UI/ItemInResponse.uasset b/Content/Scripts/DialogueSystem/UI/ItemInResponse.uasset deleted file mode 100644 index 3a4766da..00000000 --- a/Content/Scripts/DialogueSystem/UI/ItemInResponse.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:409e512dabed43ac18c9300995905689f7d99e5cabad5a42468efc13ad87224b -size 48743 diff --git a/Content/Scripts/DialogueSystem/UI/QuestingMessage.uasset b/Content/Scripts/DialogueSystem/UI/QuestingMessage.uasset deleted file mode 100644 index 4d8ca1bd..00000000 --- a/Content/Scripts/DialogueSystem/UI/QuestingMessage.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7664dfd652d6e8ac0d364cb37f20634b8409b87d81e36a611a02b32a3e10672c -size 99420 diff --git a/Content/Scripts/DialogueSystem/UI/QuestingNpcTab.uasset b/Content/Scripts/DialogueSystem/UI/QuestingNpcTab.uasset deleted file mode 100644 index 118dbf0c..00000000 --- a/Content/Scripts/DialogueSystem/UI/QuestingNpcTab.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1b96a457887cd1e29873c79a8482d6688f4064646cbb539aa82603bd685fea21 -size 64560 diff --git a/Content/Scripts/DialogueSystem/UI/QuestingSingleLine.uasset b/Content/Scripts/DialogueSystem/UI/QuestingSingleLine.uasset deleted file mode 100644 index 8e8f2ffa..00000000 --- a/Content/Scripts/DialogueSystem/UI/QuestingSingleLine.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2b6372e4673e41014e55731d62616c3bb4a433e20fe5e6247ff378636f420a0c -size 116402 diff --git a/Content/Scripts/DialogueSystem/UI/QuestingWindow.uasset b/Content/Scripts/DialogueSystem/UI/QuestingWindow.uasset deleted file mode 100644 index 3d32f04d..00000000 --- a/Content/Scripts/DialogueSystem/UI/QuestingWindow.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:19098596e105964150375bcc55edfb320b448ecd7f12e342e3c5c0371786efc8 -size 608253 diff --git a/Content/Scripts/DialogueSystem/UI/SelectableDialogueTextButtonStyle.uasset b/Content/Scripts/DialogueSystem/UI/SelectableDialogueTextButtonStyle.uasset deleted file mode 100644 index 0a57bfb3..00000000 --- a/Content/Scripts/DialogueSystem/UI/SelectableDialogueTextButtonStyle.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:49f47d27c2d073ce3fa9ea4419e3062f750b5b5a3a135f0c82c928d0f569b92f -size 7795 diff --git a/Content/Scripts/InventorySystem/ItemManagerBP.uasset b/Content/Scripts/InventorySystem/ItemManagerBP.uasset index 6cfe00c1..a58cbfdc 100644 --- a/Content/Scripts/InventorySystem/ItemManagerBP.uasset +++ b/Content/Scripts/InventorySystem/ItemManagerBP.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5583f88f57041c8ba0c1559fd81a59500922267dc05b36969a8ac5ea3fb30e5 -size 329486 +oid sha256:ea23214f8059da93a59998308f30cc81482678d5cf779fb427cfc4469da4c013 +size 329256 diff --git a/Content/ThirdPersonBP/Blueprints/BP_AnimNotifyFootsteps.uasset b/Content/ThirdPersonBP/Blueprints/BP_AnimNotifyFootsteps.uasset index 52be796a..85966d38 100644 --- a/Content/ThirdPersonBP/Blueprints/BP_AnimNotifyFootsteps.uasset +++ b/Content/ThirdPersonBP/Blueprints/BP_AnimNotifyFootsteps.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b291df760c402a634b9b96d1e58d92d95b244134a5938e9bee6c33e1b886b10 -size 72945 +oid sha256:eb796b96b8dd306f7647cec6a032254ce8fa43fdc3907223b8719a58e7f7418e +size 74447 diff --git a/Content/ThirdPersonBP/Blueprints/BP_ThirdPersonCharacter.uasset b/Content/ThirdPersonBP/Blueprints/BP_ThirdPersonCharacter.uasset index 2dc092d0..eaf8e2a8 100644 --- a/Content/ThirdPersonBP/Blueprints/BP_ThirdPersonCharacter.uasset +++ b/Content/ThirdPersonBP/Blueprints/BP_ThirdPersonCharacter.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:21d1eff933faeeffe9e1910cc20a719d5aa83b4616fba2d9f4c91edd3354bd3b -size 1124798 +oid sha256:d0e8e810122b1e51918f77dd90b00270e35c86a6e1d665c95f6e0cdcca79ff25 +size 1116121 diff --git a/Content/ThirdPersonBP/Blueprints/MainPlayerController.uasset b/Content/ThirdPersonBP/Blueprints/MainPlayerController.uasset index 15aeee65..5d066bfd 100644 --- a/Content/ThirdPersonBP/Blueprints/MainPlayerController.uasset +++ b/Content/ThirdPersonBP/Blueprints/MainPlayerController.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:65116fb35eef56f0d936b653f86324f4e7fb2b2f68fca3473ba27567ea04f3db -size 75348 +oid sha256:b7bf922683f8c4a5a1f642583653a0b3e5292dfdd2842a19d417b9a78e470f75 +size 74953 diff --git a/Source/ProjectEleri/DialogueSystem/NpcDatabase.cpp b/Source/ProjectEleri/DialogueSystem/NpcDatabase.cpp deleted file mode 100644 index 5caaa096..00000000 --- a/Source/ProjectEleri/DialogueSystem/NpcDatabase.cpp +++ /dev/null @@ -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; -} diff --git a/Source/ProjectEleri/DialogueSystem/NpcDatabase.h b/Source/ProjectEleri/DialogueSystem/NpcDatabase.h deleted file mode 100644 index fa6ee53b..00000000 --- a/Source/ProjectEleri/DialogueSystem/NpcDatabase.h +++ /dev/null @@ -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 AllNpcData; - - bool FindNpc(FName NpcID, FNpc& Npc); -}; diff --git a/Source/ProjectEleri/DialogueSystem/OrderManager.cpp b/Source/ProjectEleri/DialogueSystem/OrderManager.cpp deleted file mode 100644 index da41e03c..00000000 --- a/Source/ProjectEleri/DialogueSystem/OrderManager.cpp +++ /dev/null @@ -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(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 AOrderManager::GetAllAcceptedQuests(FName NpcID) { - TArray 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 AOrderManager::GetAllDifferentNpcs() { - TArray allNpcQuests; - for (int k = 0; k < QuestDatabase->AllQuestsByNpc.Num(); ++k) { - TArray 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 AOrderManager::GetQuestsByNpc() { - TArray 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 AOrderManager::GetIncompleteQuests() { - TArray 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 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 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 AOrderManager::GetAllAcceptedQuestForSaving() { - TArray quests; - quests = QuestList; - return quests; -} - -void AOrderManager::LoadQuestsIntoMemory(TArray 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; -} diff --git a/Source/ProjectEleri/DialogueSystem/OrderManager.h b/Source/ProjectEleri/DialogueSystem/OrderManager.h deleted file mode 100644 index a2136b52..00000000 --- a/Source/ProjectEleri/DialogueSystem/OrderManager.h +++ /dev/null @@ -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 QuestQueue; - - UPROPERTY(VisibleAnywhere) - ATimeOfDayManager* TimeOfDayActor; - - UPROPERTY(EditDefaultsOnly) - UQuestDatabase* QuestDatabase; - - UPROPERTY(EditDefaultsOnly) - UNpcDatabase* NpcDatabase; - - UPROPERTY(EditAnywhere) - int32 QuestsCompletedLastDay = 2; - - UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Quest") - TArray 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 GetIncompleteQuests(); - void AcceptQuest(FName QuestID, int32 currentDay, int32 response, TArray itemArray); - void AcceptQuest(FName QuestID, int32 currentDay); - - UFUNCTION(BlueprintCallable) - bool SubmitAnswerWithItems(FName QuestID, int32 ResponseIndex, TArray ItemIndexes); - - UFUNCTION(BlueprintCallable) - void SubmitAnswer(FName QuestID, int32 ResponseIndex); - - UFUNCTION(BlueprintCallable) - FQuest GetQuestInfo(FName QuestID); - - //Helpers for accepted quests - - UFUNCTION(BlueprintCallable) - TArray GetAllAcceptedQuestForSaving(); - - UFUNCTION(BlueprintCallable) - void LoadQuestsIntoMemory(TArray quests); - - UFUNCTION(BlueprintCallable) - FQuestItem GetAcceptedQuestInfo(FName QuestID); - - UFUNCTION(BlueprintCallable) - TArray GetAllAcceptedQuests(FName NpcID); - - UFUNCTION(BlueprintCallable) - TArray GetAllDifferentNpcs(); - - UFUNCTION(BlueprintCallable) - FNpc GetNpc(FName NpcID); - - - //Helpers for unaccepted quests - - UFUNCTION(BlueprintCallable) - TArray GetQuestsByNpc(); - -}; diff --git a/Source/ProjectEleri/DialogueSystem/PlayerQuest.cpp b/Source/ProjectEleri/DialogueSystem/PlayerQuest.cpp deleted file mode 100644 index 7ce2815b..00000000 --- a/Source/ProjectEleri/DialogueSystem/PlayerQuest.cpp +++ /dev/null @@ -1 +0,0 @@ -#include "PlayerQuest.h" diff --git a/Source/ProjectEleri/DialogueSystem/PlayerQuest.h b/Source/ProjectEleri/DialogueSystem/PlayerQuest.h deleted file mode 100644 index 5f282702..00000000 --- a/Source/ProjectEleri/DialogueSystem/PlayerQuest.h +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/Source/ProjectEleri/DialogueSystem/QuestDatabase.h b/Source/ProjectEleri/DialogueSystem/QuestDatabase.h index 702600d2..9f8d6a48 100644 --- a/Source/ProjectEleri/DialogueSystem/QuestDatabase.h +++ b/Source/ProjectEleri/DialogueSystem/QuestDatabase.h @@ -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 { diff --git a/Source/ProjectEleri/Items/ItemDatabase.h b/Source/ProjectEleri/Items/ItemDatabase.h index 0020bbb2..1a91d3ad 100644 --- a/Source/ProjectEleri/Items/ItemDatabase.h +++ b/Source/ProjectEleri/Items/ItemDatabase.h @@ -44,9 +44,6 @@ public: static UItemDatabase* Get(const UObject* WorldContext); - UPROPERTY(EditDefaultsOnly) - UItemContainer* ItemDatabase; - UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Alchemy Settings") TMap ElementStrength; diff --git a/Source/ProjectEleri/Private/EleriGameInstance.cpp b/Source/ProjectEleri/Private/EleriGameInstance.cpp index acc8aa3c..bcd9deac 100644 --- a/Source/ProjectEleri/Private/EleriGameInstance.cpp +++ b/Source/ProjectEleri/Private/EleriGameInstance.cpp @@ -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; diff --git a/Source/ProjectEleri/Private/EleriGameState.cpp b/Source/ProjectEleri/Private/EleriGameState.cpp index a092cf83..8b815d9c 100644 --- a/Source/ProjectEleri/Private/EleriGameState.cpp +++ b/Source/ProjectEleri/Private/EleriGameState.cpp @@ -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; } diff --git a/Source/ProjectEleri/Private/EleriPlayerController.cpp b/Source/ProjectEleri/Private/EleriPlayerController.cpp index 4c20ad0f..338a9461 100644 --- a/Source/ProjectEleri/Private/EleriPlayerController.cpp +++ b/Source/ProjectEleri/Private/EleriPlayerController.cpp @@ -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)) diff --git a/Source/ProjectEleri/Private/EleriSaveGame.cpp b/Source/ProjectEleri/Private/EleriSaveGame.cpp index b08b1e79..a056070f 100644 --- a/Source/ProjectEleri/Private/EleriSaveGame.cpp +++ b/Source/ProjectEleri/Private/EleriSaveGame.cpp @@ -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(); diff --git a/Source/ProjectEleri/Public/EleriPlayerController.h b/Source/ProjectEleri/Public/EleriPlayerController.h index facf957a..3f73eadd 100644 --- a/Source/ProjectEleri/Public/EleriPlayerController.h +++ b/Source/ProjectEleri/Public/EleriPlayerController.h @@ -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 MainGameWidgetBlueprint; - UPROPERTY(EditDefaultsOnly, Category = "UI") - TSubclassOf OrdersBlueprint; - UPROPERTY(EditDefaultsOnly, Category = "UI") TSubclassOf 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) diff --git a/Source/ProjectEleri/Public/EleriSaveGame.h b/Source/ProjectEleri/Public/EleriSaveGame.h index 797e28e1..2be15ca3 100644 --- a/Source/ProjectEleri/Public/EleriSaveGame.h +++ b/Source/ProjectEleri/Public/EleriSaveGame.h @@ -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 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 QuestSaveData; + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Seeds") TMap 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; }; diff --git a/Source/ProjectEleri/QuestSystem/QuestDataAsset.cpp b/Source/ProjectEleri/QuestSystem/QuestDataAsset.cpp new file mode 100644 index 00000000..f1bc946b --- /dev/null +++ b/Source/ProjectEleri/QuestSystem/QuestDataAsset.cpp @@ -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& Requirements, const UObject* Context) + { + for (const FInstancedStruct& Requirement : Requirements) + { + const FGameEventRequirement* Typed = Requirement.GetPtr(); + 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>& 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 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; +} diff --git a/Source/ProjectEleri/QuestSystem/QuestDataAsset.h b/Source/ProjectEleri/QuestSystem/QuestDataAsset.h new file mode 100644 index 00000000..7bb894fc --- /dev/null +++ b/Source/ProjectEleri/QuestSystem/QuestDataAsset.h @@ -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 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 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 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 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>& 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; +}; diff --git a/Source/ProjectEleri/QuestSystem/QuestLocationMarker.cpp b/Source/ProjectEleri/QuestSystem/QuestLocationMarker.cpp new file mode 100644 index 00000000..aaae89e6 --- /dev/null +++ b/Source/ProjectEleri/QuestSystem/QuestLocationMarker.cpp @@ -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(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; +} diff --git a/Source/ProjectEleri/QuestSystem/QuestLocationMarker.h b/Source/ProjectEleri/QuestSystem/QuestLocationMarker.h new file mode 100644 index 00000000..92a1581d --- /dev/null +++ b/Source/ProjectEleri/QuestSystem/QuestLocationMarker.h @@ -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 ArrivalSphere; + + /** The player pawn, cached because this is asked for twice a second per marker */ + TWeakObjectPtr CachedPlayer; + + FTimerHandle CheckTimerHandle; + + bool bPlayerInside = false; +}; diff --git a/Source/ProjectEleri/QuestSystem/QuestRequirements.cpp b/Source/ProjectEleri/QuestSystem/QuestRequirements.cpp new file mode 100644 index 00000000..fb6752ff --- /dev/null +++ b/Source/ProjectEleri/QuestSystem/QuestRequirements.cpp @@ -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); +} diff --git a/Source/ProjectEleri/QuestSystem/QuestRequirements.h b/Source/ProjectEleri/QuestSystem/QuestRequirements.h new file mode 100644 index 00000000..9cc54831 --- /dev/null +++ b/Source/ProjectEleri/QuestSystem/QuestRequirements.h @@ -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); +}; diff --git a/Source/ProjectEleri/QuestSystem/QuestSubsystem.cpp b/Source/ProjectEleri/QuestSystem/QuestSubsystem.cpp new file mode 100644 index 00000000..77afe093 --- /dev/null +++ b/Source/ProjectEleri/QuestSystem/QuestSubsystem.cpp @@ -0,0 +1,1449 @@ +// 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/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(); + + // 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 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(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& 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(PlayerController->GetPawn()) : nullptr; + UInventoryComponent* Inventory = Character != nullptr ? Character->GetComponentByClass() : 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()) + { + 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()) + { + 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(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(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()) + { + 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(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(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 UQuestSubsystem::GetTrackedQuestEntries() const +{ + TArray Entries; + + for (const TPair& 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 UQuestSubsystem::GetOfferableQuestEntries() const +{ + return GetQuestEntries(EQuestStatus::Available); +} + +TArray UQuestSubsystem::GetQuestEntries(EQuestStatus Status) const +{ + TArray Entries; + + for (const TObjectPtr& 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); +} + +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> Deliverables; + Quest->CollectDeliverables(State, Deliverables); + + for (const TPair& 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& 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& Friendship : Rewards.Friendship) + { + AEleriGameState::ModifyFriendshipAmount(this, Friendship.Key, Friendship.Value); + } + + for (const TPair& Experience : Rewards.Experience) + { + UMainBlueprintFunctionLibrary::ModifyExp(this, Experience.Key, Experience.Value); + } + + if (UGameInstance* GameInstance = GetGameInstance()) + { + if (UGameEventSubsystem* EventSubsystem = GameInstance->GetSubsystem()) + { + for (const TSoftObjectPtr& 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 UItemDataAsset* ItemAsset = Objective.TargetItem.LoadSynchronous(); + const FPrimaryAssetId ItemId = ItemAsset != nullptr ? ItemAsset->GetPrimaryAssetId() : FPrimaryAssetId(); + 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 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 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 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.TargetActor.IsNull() + && (Objective.TargetActor.Get() == Actor || Objective.TargetActor.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.TargetActor.IsNull() + && Objective.TargetLocationMarker.IsNull() + && !Objective.TargetNpcTag.IsValid(); +} + +void UQuestSubsystem::NotifyQuestSignal(const FQuestSignal& Signal, AActor* Source) +{ + if (!Signal.SignalTag.IsValid()) + { + return; + } + + TArray 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()) + { + 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& OutEntries) const +{ + OutEntries.Reset(); + + for (const TPair& 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& 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(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()); +} diff --git a/Source/ProjectEleri/QuestSystem/QuestSubsystem.h b/Source/ProjectEleri/QuestSystem/QuestSubsystem.h new file mode 100644 index 00000000..cb38ab3f --- /dev/null +++ b/Source/ProjectEleri/QuestSystem/QuestSubsystem.h @@ -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>& 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 GetTrackedQuestEntries() const; + + /** Everything currently on offer, which is what a giver's "anything for me?" asks */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Quest") + TArray GetOfferableQuestEntries() const; + + UFUNCTION(BlueprintCallable, Category = "Eleri|Quest") + TArray 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& 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& 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> QuestAssets; + + /** Where every quest the player has touched stands, keyed by its tag */ + UPROPERTY() + TMap QuestStates; + + UPROPERTY() + bool bQuestsLoaded = false; + +private: + + /** The world the bindings below point into. Weak, because this subsystem outlives it. */ + TWeakObjectPtr BoundWorld; + + TWeakObjectPtr BoundPlayerController; + TWeakObjectPtr BoundCharacter; + TWeakObjectPtr BoundInventory; + TWeakObjectPtr 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 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; +}; diff --git a/Source/ProjectEleri/QuestSystem/QuestTypes.cpp b/Source/ProjectEleri/QuestSystem/QuestTypes.cpp new file mode 100644 index 00000000..883c27ce --- /dev/null +++ b/Source/ProjectEleri/QuestSystem/QuestTypes.cpp @@ -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(); + 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(); +} diff --git a/Source/ProjectEleri/QuestSystem/QuestTypes.h b/Source/ProjectEleri/QuestSystem/QuestTypes.h new file mode 100644 index 00000000..3f10fe35 --- /dev/null +++ b/Source/ProjectEleri/QuestSystem/QuestTypes.h @@ -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 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 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 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 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 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 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 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 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 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 SpecialItems; + + /** Friendship with the NPCs the quest touched */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Rewards", meta = (ForceInlineRow, Categories = "Characters")) + TMap Friendship; + + /** Profession experience, by the same types the exp system uses */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Rewards", meta = (ForceInlineRow)) + TMap Experience; + + /** Events this unlocks. Started through the event subsystem, so its own requirements still apply. */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Rewards") + TArray> 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 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 Objectives; +}; diff --git a/Source/ProjectEleri/System/MainBlueprintFunctionLibrary.h b/Source/ProjectEleri/System/MainBlueprintFunctionLibrary.h index 804b8014..276f7e35 100644 --- a/Source/ProjectEleri/System/MainBlueprintFunctionLibrary.h +++ b/Source/ProjectEleri/System/MainBlueprintFunctionLibrary.h @@ -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); diff --git a/Source/ProjectEleri/System/MainlBlueprintFunctionLibrary.cpp b/Source/ProjectEleri/System/MainlBlueprintFunctionLibrary.cpp index 08ad4bfa..94461e3a 100644 --- a/Source/ProjectEleri/System/MainlBlueprintFunctionLibrary.cpp +++ b/Source/ProjectEleri/System/MainlBlueprintFunctionLibrary.cpp @@ -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())