From ff35738d783737895b2e330fa57ef6f8ef77553d Mon Sep 17 00:00:00 2001 From: "adnan.mujkic" Date: Sat, 19 Sep 2026 13:53:28 +0200 Subject: [PATCH] Initial npc schedule implementation --- Config/DefaultGame.ini | 3 + Config/DefaultGameplayTags.ini | 1 + Source/ProjectEleri/Ai/NpcScheduleAsset.cpp | 51 +++ Source/ProjectEleri/Ai/NpcScheduleAsset.h | 90 ++++- .../ProjectEleri/Ai/NpcScheduleComponent.cpp | 317 ++++++++++++++++++ Source/ProjectEleri/Ai/NpcScheduleComponent.h | 90 ++++- .../GameObjects/BaseCharacter.cpp | 60 +++- .../ProjectEleri/GameObjects/BaseCharacter.h | 23 +- .../GameObjects/BasicSplineActor.cpp | 2 + .../GameObjects/BasicSplineActor.h | 14 +- .../Private/EleriGameInstance.cpp | 29 +- .../ProjectEleri/Private/EleriGameState.cpp | 61 +++- Source/ProjectEleri/Private/EleriSaveGame.cpp | 41 ++- Source/ProjectEleri/Public/EleriGameState.h | 4 + Source/ProjectEleri/Public/EleriSaveGame.h | 12 +- .../Interface/SaveableObjectInterface.h | 40 +++ Source/ProjectEleri/Public/TimeOfDayManager.h | 8 + .../ProjectEleri/Settings/EleriGameSettings.h | 5 + .../ProjectEleri/System/EleriGameplayTags.cpp | 1 + .../ProjectEleri/System/EleriGameplayTags.h | 4 + .../System/Subsystem/ScheduleSubsystem.cpp | 158 ++++++++- .../System/Subsystem/ScheduleSubsystem.h | 61 +++- Source/ProjectEleri/System/TimeOfDayStruct.h | 8 + 23 files changed, 1026 insertions(+), 57 deletions(-) diff --git a/Config/DefaultGame.ini b/Config/DefaultGame.ini index 2e02a27b..40563c8b 100644 --- a/Config/DefaultGame.ini +++ b/Config/DefaultGame.ini @@ -197,6 +197,9 @@ ItemSeedCarouselClass=None RemovableStaticMeshClass=/Script/Engine.BlueprintGeneratedClass'/Game/Prefabs/BP_RemovableStaticMeshActor.BP_RemovableStaticMeshActor_C' MovementDisableEffectClass=/Script/Engine.BlueprintGeneratedClass'/Game/Blueprints/GameEffects/GSE_MovementDisabled.GSE_MovementDisabled_C' SpriteShadowMaterial=/Game/Materials/Sprite/MI_SpriteShadow_Inst.MI_SpriteShadow_Inst +; NpcSchedules maps an NpcTags.* gameplay tag onto the schedule asset that drives every NPC carrying +; it. Deliberately left empty until the assets are authored, the Project Settings page writes it. +; +NpcSchedules=((TagName="NpcTags.Example"), "/Game/GameData/Npc/NpcSchedules/DA_NpcSchedule_Example.DA_NpcSchedule_Example") [/Script/InteractionSystem.InteractionSettings] DefaultInteractionWidgetClass=/Script/UMG.WidgetBlueprintGeneratedClass'/Game/UI/InteractionUI/WBP_Eleri_Interaction.WBP_Eleri_Interaction_C' diff --git a/Config/DefaultGameplayTags.ini b/Config/DefaultGameplayTags.ini index 49fb9da7..f7b44405 100644 --- a/Config/DefaultGameplayTags.ini +++ b/Config/DefaultGameplayTags.ini @@ -44,6 +44,7 @@ NetIndexFirstBitSegment=16 +GameplayTagList=(Tag="Items.Crystals.Fire",DevComment="") +GameplayTagList=(Tag="Items.Crystals.Water",DevComment="") +GameplayTagList=(Tag="Items.Gold",DevComment="") ++GameplayTagList=(Tag="NpcTags",DevComment="Identity of a scheduled NPC. Every NPC carrying the same tag is driven by the same schedule asset in NpcSchedules.") +GameplayTagList=(Tag="SaveGameTags",DevComment="") +GameplayTagList=(Tag="SaveGameTags.Alchemy.TableLevel",DevComment="") +GameplayTagList=(Tag="SaveGameTags.Bazar.Table.InventorySpace",DevComment="") diff --git a/Source/ProjectEleri/Ai/NpcScheduleAsset.cpp b/Source/ProjectEleri/Ai/NpcScheduleAsset.cpp index c8394fe7..9ed98967 100644 --- a/Source/ProjectEleri/Ai/NpcScheduleAsset.cpp +++ b/Source/ProjectEleri/Ai/NpcScheduleAsset.cpp @@ -3,3 +3,54 @@ #include "NpcScheduleAsset.h" +bool FScheduledAction::ContainsTimeOfDay(int32 MinutesOfDay) const +{ + const int32 Start = StartTime.GetMinutesOfDay(); + const int32 End = EndTime.GetMinutesOfDay(); + + // An action that starts and ends at the same minute is how "covers the whole day" is authored, + // a window of literally zero length would never be useful. + if (Start == End) + { + return true; + } + + if (Start < End) + { + return MinutesOfDay >= Start && MinutesOfDay < End; + } + + // The window wraps past midnight, for example a 22:00 to 06:00 sleep. It is one continuous range, + // so everything from the start to midnight belongs to it as does everything from midnight to end. + return MinutesOfDay >= Start || MinutesOfDay < End; +} + +const FScheduledAction* UNpcScheduleAsset::FindActionForTime(int32 MinutesOfDay) const +{ + const FScheduledAction* Best = nullptr; + + for (const FScheduledAction& Action : ScheduledActions) + { + if (!Action.ContainsTimeOfDay(MinutesOfDay)) + { + continue; + } + + // Strictly greater, so the action authored earlier in the list wins a priority tie and the + // result stays stable no matter what order the windows were visited in. + if (Best == nullptr || Action.Priority > Best->Priority) + { + Best = &Action; + } + } + + return Best; +} + +bool FResolvedNpcAction::IsSameAssignment(const FResolvedNpcAction& Other) const +{ + return Activity == Other.Activity + && AnchorGuid == Other.AnchorGuid + && RoamPathGuid == Other.RoamPathGuid; +} + diff --git a/Source/ProjectEleri/Ai/NpcScheduleAsset.h b/Source/ProjectEleri/Ai/NpcScheduleAsset.h index c1488a7e..413b0533 100644 --- a/Source/ProjectEleri/Ai/NpcScheduleAsset.h +++ b/Source/ProjectEleri/Ai/NpcScheduleAsset.h @@ -5,14 +5,29 @@ #include "CoreMinimal.h" #include "Engine/DataAsset.h" #include "ProjectEleri/System/TimeOfDayStruct.h" -#include "ProjectEleri/GameObjects/BasicSplineActor.h" #include "NpcScheduleAsset.generated.h" +class AActor; +/** What an NPC is meant to be doing during a scheduled window. The schedule only states the intent, + * the StateTree turns it into a behaviour, so adding a behaviour means adding an enum value here + * plus a branch in ST_ScheduledNpc, without touching any of the scheduling code. */ +UENUM(BlueprintType) +enum class ENpcActivity : uint8 +{ + Idle UMETA(DisplayName = "Idle"), + Sleep UMETA(DisplayName = "Sleep"), + Sit UMETA(DisplayName = "Sit"), + Stand UMETA(DisplayName = "Stand"), + Work UMETA(DisplayName = "Work"), + Roam UMETA(DisplayName = "Roam") +}; + +/** One window in an NPC's day, for example "sleep at this bed from 22:00 to 06:00". */ USTRUCT(BlueprintType) struct FScheduledAction { - GENERATED_BODY(); + GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadOnly) FTimeOfDayData StartTime; @@ -20,24 +35,33 @@ struct FScheduledAction { UPROPERTY(EditAnywhere, BlueprintReadOnly) FTimeOfDayData EndTime; + UPROPERTY(EditAnywhere, BlueprintReadOnly) + ENpcActivity Activity = ENpcActivity::Idle; + + /** PersistentId of the actor this action happens at, usually a bed or a chair. An invalid Guid + * means the action is not tied to any anchor. */ UPROPERTY(EditAnywhere, BlueprintReadOnly) FGuid UsableObjectGuid; -}; - -USTRUCT(BlueprintType) -struct FScheduleInstance { - GENERATED_BODY() - + /** PersistentId of the ABasicSplineActor to walk along, used by ENpcActivity::Roam. Stores the id + * rather than an actor pointer so a re-cook or a re-save cannot leave the entry dangling. */ UPROPERTY(EditAnywhere, BlueprintReadOnly) - TArray ScheduledActions; + FGuid RoamPathGuid; + /** Higher wins when two windows overlap. Equal priorities fall back to authoring order. */ UPROPERTY(EditAnywhere, BlueprintReadOnly) - TArray ActionPaths; + int32 Priority = 0; + + /** True when MinutesOfDay falls inside this window. The start is inclusive and the end exclusive, + * so a 06:00-22:00 and a 22:00-06:00 window tile a whole day without overlapping. Windows that + * wrap past midnight are treated as one continuous range, and an action whose start and end are + * equal covers the whole day, which is how "always" is authored. */ + bool ContainsTimeOfDay(int32 MinutesOfDay) const; }; /** - * + * The full day plan for one NPC. Which asset an NPC uses is decided by its NpcTag, looked up in + * UEleriGameSettings::NpcSchedules, so one routine can be shared by many NPCs. */ UCLASS() class PROJECTELERI_API UNpcScheduleAsset : public UDataAsset @@ -48,4 +72,48 @@ public: UPROPERTY(EditAnywhere, BlueprintReadOnly) TArray ScheduledActions; + /** Highest priority action whose window contains MinutesOfDay, or nullptr when the clock sits in + * a gap between authored windows. Ties are broken by authoring order, the first one wins. */ + const FScheduledAction* FindActionForTime(int32 MinutesOfDay) const; +}; + +/** The answer to "where should this NPC be right now". This is what gets handed to the StateTree, + * so it carries resolved objects rather than the raw schedule entry. */ +USTRUCT(BlueprintType) +struct FResolvedNpcAction +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite) + ENpcActivity Activity = ENpcActivity::Idle; + + /** The actor the action happens at, usually a bed or a chair. Null when the action has no anchor, + * or when the anchor sits in a streamed out cell and could not be found. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite) + TWeakObjectPtr Anchor; + + /** Where that anchor was the last time the schedule subsystem could see it. This is the fallback + * used when Anchor is null, because the NPCs themselves never stream out but the furniture they + * walk to does. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite) + FTransform AnchorTransform = FTransform::Identity; + + UPROPERTY(EditAnywhere, BlueprintReadWrite) + FGuid AnchorGuid; + + /** The spline to roam along for ENpcActivity::Roam, null when the path is streamed out. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite) + TWeakObjectPtr RoamPath; + + UPROPERTY(EditAnywhere, BlueprintReadWrite) + FGuid RoamPathGuid; + + /** True when Anchor points at a live actor. When it is false and AnchorGuid is valid the anchor + * has streamed out and AnchorTransform holds where it was last seen. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite) + bool bAnchorResolved = false; + + /** Identity for the "did this NPC's assignment actually change" check. Deliberately ignores the + * transforms, moving a bed while an NPC is already asleep should not restart the StateTree. */ + bool IsSameAssignment(const FResolvedNpcAction& Other) const; }; diff --git a/Source/ProjectEleri/Ai/NpcScheduleComponent.cpp b/Source/ProjectEleri/Ai/NpcScheduleComponent.cpp index b3d83b3b..6cb16b8f 100644 --- a/Source/ProjectEleri/Ai/NpcScheduleComponent.cpp +++ b/Source/ProjectEleri/Ai/NpcScheduleComponent.cpp @@ -1,3 +1,320 @@ // Fill out your copyright notice in the Description page of Project Settings. #include "NpcScheduleComponent.h" +#include "ProjectEleri/GameObjects/BaseCharacter.h" +#include "ProjectEleri/System/EleriGameplayTags.h" +#include "ProjectEleri/System/Subsystem/ScheduleSubsystem.h" +#include "ProjectEleri/Public/TimeOfDayManager.h" +#include "ProjectEleri/Public/EleriGameState.h" +#include "GameplayStateTreeModule/Public/Components/StateTreeComponent.h" +#include "GameFramework/PawnMovementComponent.h" +#include "StructUtils/InstancedStruct.h" +#include "Engine/GameInstance.h" +#include "Kismet/GameplayStatics.h" + +namespace +{ + /** How often the component looks at the clock and at how far away the player is. A schedule only + * ever changes when the in game minute does, and the freeze distance does not need frame + * accuracy, so there is nothing to gain from looking more often than this. */ + constexpr float ScheduleTickInterval = 0.5f; +} + +UNpcScheduleComponent::UNpcScheduleComponent() +{ + PrimaryComponentTick.bCanEverTick = true; + PrimaryComponentTick.TickInterval = ScheduleTickInterval; +} + +void UNpcScheduleComponent::BeginPlay() +{ + Super::BeginPlay(); + + // A fresh session finishes loading the save after the level is already up, so this finds nothing + // then and AEleriGameState::PropagateSaveData does the work instead. What this catches is anything + // that appears afterwards, a customer spawned by a spawner or a second load, which is the same + // division of labour AEleriBaseActor uses. + if (const AEleriGameState* GameState = AEleriGameState::Get(this)) + { + FNpcScheduleSaveData Data; + if (GameState->RequestNpcScheduleData(NpcTag, Data)) + { + ApplySaveData(Data); + } + } + + // There is no view target yet at this point, because on level load the world's actors are brought + // up before the player pawn exists. That first pass is therefore forced to frozen, which is what + // keeps every NPC's StateTree unloaded until there is a real distance to judge them by. The first + // tick after the pawn appears corrects whichever NPCs turn out to be in range. + SetFrozen(FindViewTargetActor() != nullptr ? ShouldBeFrozen() : true); +} + +FNpcScheduleSaveData UNpcScheduleComponent::PrepareSaveData() const +{ + FNpcScheduleSaveData Data; + Data.NpcTag = NpcTag; + Data.bHasAction = bHasAction; + Data.Activity = static_cast(CurrentAction.Activity); + Data.AnchorGuid = CurrentAction.AnchorGuid; + Data.RoamPathGuid = CurrentAction.RoamPathGuid; + + // The live transform rather than the assignment's anchor, because an NPC caught mid walk is + // somewhere between the two, and that is exactly where the player left it. + if (const AActor* Owner = GetOwner()) + { + Data.Transform = Owner->GetActorTransform(); + } + + return Data; +} + +void UNpcScheduleComponent::ApplySaveData(const FNpcScheduleSaveData& Data) +{ + // One record per tag, so a mismatch means this component is not the NPC the record describes. + if (Data.NpcTag != NpcTag) + { + return; + } + + if (AActor* Owner = GetOwner()) + { + // Teleport physics, so an NPC loaded into geometry cannot be shoved away from the saved spot by + // the very move that is meant to put it there. + Owner->SetActorTransform(Data.Transform, false, nullptr, ETeleportType::TeleportPhysics); + } + + if (Data.bHasAction) + { + // Rebuilt instead of assigned wholesale, because the record deliberately holds ids rather than + // actors and the weak pointers it does not carry would otherwise be overwritten with garbage. + CurrentAction = FResolvedNpcAction(); + CurrentAction.Activity = static_cast(Data.Activity); + CurrentAction.AnchorGuid = Data.AnchorGuid; + CurrentAction.RoamPathGuid = Data.RoamPathGuid; + bHasAction = true; + } + + // Resolves the ids above into live actors before the StateTree ever looks at them. The assignment + // already matches the clock by now, so this refreshes the pointers and the anchor transform rather + // than pushing a fresh event at a tree that may already be running. + RefreshSchedule(); +} + +void UNpcScheduleComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) +{ + Super::TickComponent(DeltaTime, TickType, ThisTickFunction); + + const bool bShouldFreeze = ShouldBeFrozen(); + if (bShouldFreeze != bFrozen) + { + SetFrozen(bShouldFreeze); + } + + // A frozen NPC is far enough away that the player cannot see what it is doing, so there is no + // point working out what it ought to be doing. Its unfreeze re-reads the clock anyway, which is + // why the schedule never has to be caught up frame by frame while it is asleep. + if (!bFrozen) + { + RefreshSchedule(); + } +} + +void UNpcScheduleComponent::RefreshSchedule() +{ + if (!NpcTag.IsValid()) + { + return; + } + + UScheduleSubsystem* Subsystem = GetScheduleSubsystem(); + ATimeOfDayManager* Clock = FindTimeOfDayManager(); + if (Subsystem == nullptr || Clock == nullptr) + { + return; + } + + FResolvedNpcAction Resolved; + if (!Subsystem->ResolveAction(NpcTag, Clock->GetTimeOfDay().GetMinutesOfDay(), Resolved)) + { + // The clock is sitting in a gap between this NPC's windows. It carries on with whatever it was + // already doing rather than being reset to having nothing to do. + return; + } + + if (bHasAction && CurrentAction.IsSameAssignment(Resolved)) + { + // Same assignment, so only the parts that can change without the assignment changing are + // refreshed. Sending the event again would restart whatever task the StateTree is in the + // middle of, and the NPC would never actually get anywhere. + CurrentAction.Anchor = Resolved.Anchor; + CurrentAction.AnchorTransform = Resolved.AnchorTransform; + CurrentAction.bAnchorResolved = Resolved.bAnchorResolved; + CurrentAction.RoamPath = Resolved.RoamPath; + return; + } + + CurrentAction = Resolved; + bHasAction = true; + + UStateTreeComponent* Tree = GetStateTreeComponent(); + if (Tree == nullptr) + { + return; + } + + FInstancedStruct Payload; + Payload.InitializeAs(); + Payload.GetMutable() = CurrentAction; + + Tree->SendStateTreeEvent(TimeOfDay::ScheduleActionChanged, Payload, FName()); +} + +void UNpcScheduleComponent::SetFrozen(bool bNewFrozen) +{ + // The very first call always goes through, even when it asks for the state bFrozen already holds, + // because applying the unfrozen state is what gets an in range NPC's logic started. + if (bFreezeStateApplied && bFrozen == bNewFrozen) + { + return; + } + + bFrozen = bNewFrozen; + bFreezeStateApplied = true; + + ApplyFreezeState(); +} + +void UNpcScheduleComponent::ApplyFreezeState() +{ + AActor* Owner = GetOwner(); + if (Owner == nullptr) + { + return; + } + + UPawnMovementComponent* Movement = Owner->FindComponentByClass(); + + if (bFrozen) + { + // Brought to a stop before its tick goes off, otherwise it would keep coasting on its last + // velocity with nothing left running to correct it. + if (Movement != nullptr) + { + Movement->StopMovementImmediately(); + Movement->SetComponentTickEnabled(false); + } + + Owner->SetActorHiddenInGame(true); + Owner->SetActorEnableCollision(false); + Owner->SetActorTickEnabled(false); + + if (UStateTreeComponent* Tree = GetStateTreeComponent()) + { + // Paused rather than stopped, so the task it was in the middle of is still there to be + // picked up again when the player comes back. + Tree->PauseLogic(TEXT("NpcOutOfRange")); + } + + return; + } + + // Unfrozen. The NPC is put back exactly as it was and then handed back to its StateTree. + if (Movement != nullptr) + { + Movement->SetComponentTickEnabled(true); + } + + Owner->SetActorHiddenInGame(false); + Owner->SetActorEnableCollision(true); + Owner->SetActorTickEnabled(true); + + UStateTreeComponent* Tree = GetStateTreeComponent(); + if (Tree == nullptr) + { + return; + } + + if (Tree->GetStateTreeRunStatus() == EStateTreeRunStatus::Running) + { + // Resume, never Start. Start sends the tree back to its root, so the NPC would begin whatever + // it was doing over again instead of carrying on. + Tree->ResumeLogic(TEXT("NpcInRange")); + return; + } + + // Either the very first unfreeze, or a tree that stopped for some other reason. Asking the + // character means it gets to bring its own soft referenced StateTree in first when it has to. + if (ABaseCharacter* Character = Cast(Owner)) + { + Character->StartCharacterLogic(); + } + else + { + Tree->StartLogic(); + } +} + +bool UNpcScheduleComponent::ShouldBeFrozen() +{ + const AActor* Owner = GetOwner(); + if (Owner == nullptr) + { + return false; + } + + const AActor* ViewTarget = FindViewTargetActor(); + if (ViewTarget == nullptr) + { + // Nobody to be far from, which is what happens once the player is gone. Left unfrozen so that + // a missing player can never leave an NPC permanently inert. The startup case, where the pawn + // simply has not been spawned yet, is handled separately and deliberately by BeginPlay. + return false; + } + + return FVector::DistSquared(Owner->GetActorLocation(), ViewTarget->GetActorLocation()) + > FMath::Square(FreezeDistance); +} + +AActor* UNpcScheduleComponent::FindViewTargetActor() +{ + if (CachedViewTarget.IsValid()) + { + return CachedViewTarget.Get(); + } + + CachedViewTarget = UGameplayStatics::GetPlayerPawn(this, 0); + return CachedViewTarget.Get(); +} + +ATimeOfDayManager* UNpcScheduleComponent::FindTimeOfDayManager() +{ + if (CachedTimeOfDayManager.IsValid()) + { + return CachedTimeOfDayManager.Get(); + } + + CachedTimeOfDayManager = Cast( + UGameplayStatics::GetActorOfClass(this, ATimeOfDayManager::StaticClass())); + + return CachedTimeOfDayManager.Get(); +} + +UStateTreeComponent* UNpcScheduleComponent::GetStateTreeComponent() +{ + const AActor* Owner = GetOwner(); + return Owner != nullptr ? Owner->FindComponentByClass() : nullptr; +} + +UScheduleSubsystem* UNpcScheduleComponent::GetScheduleSubsystem() +{ + const UWorld* World = GetWorld(); + if (World == nullptr) + { + return nullptr; + } + + // A game instance subsystem, so it outlives every level and there is nothing to cache here. + UGameInstance* GameInstance = World->GetGameInstance(); + return GameInstance != nullptr ? GameInstance->GetSubsystem() : nullptr; +} diff --git a/Source/ProjectEleri/Ai/NpcScheduleComponent.h b/Source/ProjectEleri/Ai/NpcScheduleComponent.h index 4af94aa7..3de932ac 100644 --- a/Source/ProjectEleri/Ai/NpcScheduleComponent.h +++ b/Source/ProjectEleri/Ai/NpcScheduleComponent.h @@ -4,24 +4,102 @@ #include "CoreMinimal.h" #include "Components/ActorComponent.h" -#include "StructUtils/InstancedStruct.h" +#include "GameplayTagContainer.h" #include "ProjectEleri/Ai/NpcScheduleAsset.h" +#include "ProjectEleri/Public/Interface/SaveableObjectInterface.h" #include "NpcScheduleComponent.generated.h" +class ATimeOfDayManager; +class UStateTreeComponent; +class UScheduleSubsystem; + /** + * Drives one NPC from its schedule, and keeps that NPC frozen while the player is nowhere near it. * + * This ticks at a low fixed rate instead of every frame. A schedule is a pure function of the NPC's + * tag and the minute of the day, so an update is a map lookup and a scan of a handful of time + * windows, and nothing about it needs per frame resolution. + * + * The component keeps ticking while the NPC it belongs to is frozen, because noticing the player + * coming back is the one thing that cannot be switched off. */ UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent), BlueprintType, Blueprintable) -class PROJECTELERI_API UNpcScheduleComponent : public UActorComponent +class PROJECTELERI_API UNpcScheduleComponent : public UActorComponent { GENERATED_BODY() +public: + + UNpcScheduleComponent(); + + virtual void BeginPlay() override; + virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; + + /** Which schedule this NPC follows, looked up in UEleriGameSettings::NpcSchedules. An empty tag + * means the NPC is not scheduled and the component stays out of the way. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Schedule", meta = (Categories = "NpcTags")) + FGameplayTag NpcTag; + + /** Past this distance from the player the NPC is frozen in place, which switches off its tick, its + * StateTree, its movement and its collision. The NPC itself stays resident because it is not + * spatially loaded, so it is still there holding its position when the player comes back. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Schedule", meta = (ClampMin = "0.0", UIMin = "0.0")) + float FreezeDistance = 6000.0f; + + /** What this NPC was last told to do */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Schedule") + FResolvedNpcAction CurrentAction; + + /** True once the block above has run at least once */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Schedule") + bool bHasAction = false; + + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Schedule") + bool IsFrozen() const { return bFrozen; } + + /** Re-reads the clock and, when the assignment actually changed, pushes the new one at the + * StateTree as a TimeOfDay.ScheduleActionChanged event carrying an FResolvedNpcAction. */ + UFUNCTION(BlueprintCallable, Category = "Schedule") + void RefreshSchedule(); + + UFUNCTION(BlueprintCallable, Category = "Schedule") + void SetFrozen(bool bNewFrozen); + + /** Everything about this NPC that a load has to put back: where it stands, and the assignment it + * was on, so the first refresh after the load recognises the schedule's answer as the one it + * already had instead of handing the NPC a fresh one. */ + FNpcScheduleSaveData PrepareSaveData() const; + + /** Puts an NPC back on the spot and the assignment it was saved with. Records for a different tag + * are ignored, and an NPC that had no assignment yet is left to the schedule. */ + void ApplySaveData(const FNpcScheduleSaveData& Data); + protected: - UPROPERTY(EditAnywhere) - TObjectPtr ScheduleAsset; + /** The manager, cached because GetActorOfClass walks every actor in the level and this is asked for + * twice a second by every NPC. */ + ATimeOfDayManager* FindTimeOfDayManager(); - UPROPERTY(EditAnywhere) - TInstancedStruct Schedule; + /** The pawn distances are measured from, cached for the same reason as the manager. */ + AActor* FindViewTargetActor(); + bool ShouldBeFrozen(); + + /** Carries out whatever bFrozen currently asks for. Kept separate from SetFrozen so that the first + * application always happens, which is what gets the logic of an in range NPC started. */ + void ApplyFreezeState(); + + UStateTreeComponent* GetStateTreeComponent(); + + UScheduleSubsystem* GetScheduleSubsystem(); + +private: + + TWeakObjectPtr CachedTimeOfDayManager; + TWeakObjectPtr CachedViewTarget; + + /** False until the freeze state has been applied once */ + bool bFreezeStateApplied = false; + + bool bFrozen = false; }; \ No newline at end of file diff --git a/Source/ProjectEleri/GameObjects/BaseCharacter.cpp b/Source/ProjectEleri/GameObjects/BaseCharacter.cpp index f8211348..8741c339 100644 --- a/Source/ProjectEleri/GameObjects/BaseCharacter.cpp +++ b/Source/ProjectEleri/GameObjects/BaseCharacter.cpp @@ -12,6 +12,7 @@ #include "Kismet/GameplayStatics.h" #include "ProjectEleri/System/TimeOfDayStruct.h" #include "ProjectEleri/System/EleriGameplayTags.h" +#include "ProjectEleri/Ai/NpcScheduleComponent.h" ABaseCharacter::ABaseCharacter() { @@ -35,6 +36,15 @@ ABaseCharacter::ABaseCharacter() PrimaryActorTick.bCanEverTick = true; StateTreeComponent = CreateDefaultSubobject("State Tree Component"); StateTreeComponent->SetStartLogicAutomatically(false); + + ScheduleComponent = CreateDefaultSubobject("Npc Schedule Component"); + + // Resident wherever the player is, because a schedule is driven by the clock rather than by + // proximity, and the whole point is that the NPC is already in the right place by the time the + // player arrives. Being frozen in place by the schedule component while out of range is what + // makes that affordable. Assigned as a plain member because SetIsSpatiallyLoaded() only exists in + // editor builds and this has to hold in a packaged one too. + bIsSpatiallyLoaded = false; WidgetComponent = CreateDefaultSubobject(TEXT("WidgetComponent")); WidgetComponent->SetupAttachment(RootComponent); @@ -50,17 +60,12 @@ void ABaseCharacter::BeginPlay() { FaceExpressionData.LoadAsync(FLoadSoftObjectPathAsyncDelegate::CreateUObject(this, &ABaseCharacter::OnFaceDataLoaded)); } - if (!CharacterStateTree.IsNull()) { - if (CharacterStateTree.IsValid()) { - StateTreeComponent->SetStateTree(CharacterStateTree.Get()); - StateTreeComponent->StartLogic(); - } - else { - CharacterStateTree.LoadAsync(FLoadSoftObjectPathAsyncDelegate::CreateLambda([&](const FSoftObjectPath& Path, UObject* Obj) { - StateTreeComponent->SetStateTree(CharacterStateTree.Get()); - StateTreeComponent->StartLogic(); - })); - } + // A schedule component holds the logic back and starts it on this character's first unfreeze, so + // there is deliberately nothing to start here. The fallback stays for anything the component has + // been taken off. + if (ScheduleComponent == nullptr) + { + StartCharacterLogic(); } if (!CharacterStateTree.IsNull()) { @@ -85,6 +90,39 @@ void ABaseCharacter::OnMinutesAdded(FTimeOfDayData TimeOfDay) { StateTreeComponent->SendStateTreeEvent(TimeOfDay::TimePassed, Payload, FName()); } +void ABaseCharacter::StartCharacterLogic() +{ + if (StateTreeComponent == nullptr || CharacterStateTree.IsNull()) + { + return; + } + + // Already up. Guarded because the schedule component asks for this every time the NPC comes back + // into range, and starting a running tree again would send it back to its root. + if (StateTreeComponent->GetStateTreeRunStatus() == EStateTreeRunStatus::Running) + { + return; + } + + if (UStateTree* LoadedStateTree = CharacterStateTree.Get()) + { + StateTreeComponent->SetStateTree(LoadedStateTree); + StateTreeComponent->StartLogic(); + return; + } + + // Not in memory yet. The schedule component is what triggered this, so the NPC is in range and + // the load is worth paying for now. + CharacterStateTree.LoadAsync(FLoadSoftObjectPathAsyncDelegate::CreateUObject(this, &ABaseCharacter::OnStateTreeAsyncLoaded)); +} + +void ABaseCharacter::OnStateTreeAsyncLoaded(const FSoftObjectPath &Path, UObject *Object) +{ + // Going through the same entry point keeps the "only ever start a stopped tree" rule in one place. + // By now the soft reference resolves, so this run takes the immediate path. + StartCharacterLogic(); +} + void ABaseCharacter::OnFaceDataLoaded(const FSoftObjectPath &Path, UObject *Object) { FaceExpressionData = Cast(Object); diff --git a/Source/ProjectEleri/GameObjects/BaseCharacter.h b/Source/ProjectEleri/GameObjects/BaseCharacter.h index 675b6bd8..fad9e2b5 100644 --- a/Source/ProjectEleri/GameObjects/BaseCharacter.h +++ b/Source/ProjectEleri/GameObjects/BaseCharacter.h @@ -17,6 +17,7 @@ class UDialogueComponent; class UStateTreeAIComponent; class UCapsuleComponent; class USkeletalMeshComponent; +class UNpcScheduleComponent; UCLASS(BlueprintType) class PROJECTELERI_API ABaseCharacter : public APawn, public IInteractableActorInterface, public INpcSpeakerDataInterface @@ -34,11 +35,23 @@ public: UFUNCTION(BlueprintCallable) void ChangeFaceAnimation(EDialogueEmotion Emotion); + /** Brings this character's StateTree in when it is not in memory yet and starts its logic, doing + * nothing at all when the tree is already running. The schedule component puts the very first + * call off until the character is actually in range, which is what keeps an NPC the player never + * goes near from ever paying for its StateTree. */ + UFUNCTION(BlueprintCallable) + void StartCharacterLogic(); + UPROPERTY(Category = Character, EditDefaultsOnly, BlueprintReadOnly) TSoftObjectPtr CharacterStateTree; UFUNCTION(BlueprintCallable, BlueprintPure) UCapsuleComponent* GetCapsuleComponent() { return Capsule; } + + /** The schedule driving this character, null when it is not scheduled. The save layer walks this + * to store and restore where an NPC stood and what it was doing. */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Schedule") + UNpcScheduleComponent* GetScheduleComponent() const { return ScheduleComponent; } virtual void ToggleInteractableUI_Implementation(bool bActive) override; @@ -51,9 +64,13 @@ protected: UPROPERTY(Category = Character, EditDefaultsOnly, BlueprintReadOnly, meta = (AllowPrivateAccess = "true")) bool bHasFaceAnimation = false; - UPROPERTY(EditAnywhere, BlueprintReadOnly) + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components") TObjectPtr StateTreeComponent; + /** Drives this NPC from its schedule and freezes it while the player is far away */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components") + TObjectPtr ScheduleComponent; + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components") UCapsuleComponent* Capsule; @@ -73,6 +90,10 @@ protected: UFUNCTION() void OnMinutesAdded(FTimeOfDayData TimeOfDay); + /** Completion of the deferred StateTree load started by StartCharacterLogic */ + UFUNCTION() + void OnStateTreeAsyncLoaded(const FSoftObjectPath& Path, UObject* Object); + UFUNCTION() void OnFaceDataLoaded(const FSoftObjectPath& Path, UObject* Object); void SetupFaceAnimationPlayer(); diff --git a/Source/ProjectEleri/GameObjects/BasicSplineActor.cpp b/Source/ProjectEleri/GameObjects/BasicSplineActor.cpp index 38af43ac..4cceed62 100644 --- a/Source/ProjectEleri/GameObjects/BasicSplineActor.cpp +++ b/Source/ProjectEleri/GameObjects/BasicSplineActor.cpp @@ -9,6 +9,8 @@ ABasicSplineActor::ABasicSplineActor() // Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it. PrimaryActorTick.bCanEverTick = false; + PersistentId = FGuid::NewGuid(); + USceneComponent* RootComp = CreateDefaultSubobject("Root"); SetRootComponent(RootComp); diff --git a/Source/ProjectEleri/GameObjects/BasicSplineActor.h b/Source/ProjectEleri/GameObjects/BasicSplineActor.h index 3bb05f62..666535f0 100644 --- a/Source/ProjectEleri/GameObjects/BasicSplineActor.h +++ b/Source/ProjectEleri/GameObjects/BasicSplineActor.h @@ -5,11 +5,12 @@ #include "CoreMinimal.h" #include "GameFramework/Actor.h" #include "Components/SplineComponent.h" +#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h" #include "BasicSplineActor.generated.h" UCLASS() -class PROJECTELERI_API ABasicSplineActor : public AActor +class PROJECTELERI_API ABasicSplineActor : public AActor, public IActorPersistentIdInterface { GENERATED_BODY() @@ -19,4 +20,15 @@ public: UPROPERTY(VisibleAnywhere, BlueprintReadOnly) USplineComponent* Spline; + + //----- IActorPersistentIdInterface ----- + virtual FGuid GetPersistentId() const override { return PersistentId; } + +protected: + + /** Stable identity for anything that has to point at this path, such as a roam action in an + * NPC schedule. Generated once in the constructor and then serialized with the placed instance, + * so it survives save/load and a re-cook, unlike a raw actor pointer. */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "ID") + FGuid PersistentId; }; diff --git a/Source/ProjectEleri/Private/EleriGameInstance.cpp b/Source/ProjectEleri/Private/EleriGameInstance.cpp index d21f55cc..acc8aa3c 100644 --- a/Source/ProjectEleri/Private/EleriGameInstance.cpp +++ b/Source/ProjectEleri/Private/EleriGameInstance.cpp @@ -26,12 +26,20 @@ void UEleriGameInstance::LoadGame() void UEleriGameInstance::OnLoadGame(const FString& SlotName, const int32 UserIndex, USaveGame* LoadedGameData) { - if (LoadedGameData && GetWorld()) { - if (AEleriGameState* EleriGameState = Cast(GetWorld()->GetGameState())) { - EleriGameState->PropagateSaveData(Cast(LoadedGameData)); - EleriGameState->OnPropagateSaveDataComplete.AddUniqueDynamic(this, &UEleriGameInstance::OnGameLoaded); - } + SaveGameObject = Cast(LoadedGameData); + + AEleriGameState* EleriGameState = GetWorld() ? Cast(GetWorld()->GetGameState()) : nullptr; + if (SaveGameObject == nullptr || EleriGameState == nullptr) { + // Nothing was loaded, or there is no world left to propagate into. Reporting it here is what + // keeps a failed load from looking exactly like a load that is still in progress. + OnLoadGameResult.Broadcast(false, nullptr); + return; } + + // Subscribed before the propagate, because the propagate is what fires the completion. Binding + // afterwards meant the callback was never reached and OnLoadGameResult was never broadcast at all. + EleriGameState->OnPropagateSaveDataComplete.AddUniqueDynamic(this, &UEleriGameInstance::OnGameLoaded); + EleriGameState->PropagateSaveData(SaveGameObject); } void UEleriGameInstance::SaveGame() @@ -60,9 +68,12 @@ void UEleriGameInstance::OnSaveGame(const FString& SlotName, const int32 UserInd } void UEleriGameInstance::OnGameLoaded() { - if (AEleriGameState* EleriGameState = Cast(GetWorld()->GetGameState())) { - EleriGameState->OnPropagateSaveDataComplete.RemoveDynamic(this, &UEleriGameInstance::OnGameLoaded); + if (GetWorld()) { + if (AEleriGameState* EleriGameState = Cast(GetWorld()->GetGameState())) { + EleriGameState->OnPropagateSaveDataComplete.RemoveDynamic(this, &UEleriGameInstance::OnGameLoaded); + } } - OnLoadGameResult.Broadcast(false, nullptr); -} + // The save has been read and applied, so this is what tells the menus that Continue worked + OnLoadGameResult.Broadcast(SaveGameObject != nullptr, SaveGameObject); +} diff --git a/Source/ProjectEleri/Private/EleriGameState.cpp b/Source/ProjectEleri/Private/EleriGameState.cpp index 5c3de480..f405b1ea 100644 --- a/Source/ProjectEleri/Private/EleriGameState.cpp +++ b/Source/ProjectEleri/Private/EleriGameState.cpp @@ -10,6 +10,8 @@ #include "Components/StatForgeComponent.h" #include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" #include "ProjectEleri/GameObjects/EleriBaseActor.h" +#include "ProjectEleri/Ai/NpcScheduleComponent.h" +#include "ProjectEleri/GameObjects/BaseCharacter.h" AEleriGameState* AEleriGameState::Get(const UObject* WorldContext) { @@ -112,6 +114,20 @@ void AEleriGameState::PropagateSaveData(UEleriSaveGame* InSaveData) SaveData = InSaveData->SaveData; + //The clock goes back first, because everything below it, the NPC schedules in particular, is a + //function of the time of day + ATimeOfDayManager* Clock = Cast( + UGameplayStatics::GetActorOfClass(GetWorld(), ATimeOfDayManager::StaticClass())); + if (Clock != nullptr && SaveData.TimeOfDaySave.IsValidTime()) + { + Clock->LoadSaveData(SaveData.TimeOfDaySave); + + // LoadSaveData writes the fields silently, so the clock is then set to the value it just + // adopted. That is what broadcasts OnTimePassed and OnMinutesAdded, which is how everything + // already listening hears that the time jumped instead of having to read the clock back itself. + Clock->SetTimeOfDay(Clock->GetCurrentFloatMinutes()); + } + TArray OutActors; // Load all inventories @@ -148,6 +164,23 @@ void AEleriGameState::PropagateSaveData(UEleriSaveGame* InSaveData) } } + //Put every scheduled NPC back where it was, on the assignment it was on. This comes after the clock + //above, so an assignment resolves against the time the save was made at. + for (TActorIterator It(GetWorld()); It; ++It) + { + ABaseCharacter* Npc = *It; + if (!IsValid(Npc)) continue; + + UNpcScheduleComponent* Schedule = Npc->GetScheduleComponent(); + if (Schedule == nullptr || !Schedule->NpcTag.IsValid()) continue; + + FNpcScheduleSaveData NpcSaveData; + if (RequestNpcScheduleData(Schedule->NpcTag, NpcSaveData)) + { + Schedule->ApplySaveData(NpcSaveData); + } + } + //Game event data if (GetWorld()->GetGameInstance()) { @@ -185,6 +218,10 @@ void AEleriGameState::PropagateSaveData(UEleriSaveGame* InSaveData) //Seed delete and spawn should always go last PropagateAllSeedData(); + + //Announced only once everything above has been read, so a listener that reacts to the load sees a + //finished world rather than a half restored one + OnPropagateSaveDataComplete.Broadcast(); } const FSaveData& AEleriGameState::PrepareSaveData() @@ -193,8 +230,7 @@ const FSaveData& AEleriGameState::PrepareSaveData() ATimeOfDayManager* TimeOfDay = Cast( UGameplayStatics::GetActorOfClass(GetWorld(), ATimeOfDayManager::StaticClass())); check(TimeOfDay); - SaveData.SetTimeOFDayData(TimeOfDay->GetDaysPassed(), TimeOfDay->GetMonth(), TimeOfDay->GetYear(), - TimeOfDay->GetExactMinutes(), TimeOfDay->GetCurrentHours()); + SaveData.TimeOfDaySave = TimeOfDay->GetSaveData(); //SAVE BOOK const ABookOfAlchemyManager* Book = Cast( @@ -237,6 +273,9 @@ const FSaveData& AEleriGameState::PrepareSaveData() //SAVE ACTORS SaveData.CollectSaveableActorsData(GetWorld()); + //SAVE NPC SCHEDULES + SaveData.CollectNpcScheduleData(GetWorld()); + //SAVE GAME EVENTS SaveData.SetGameEventData(GetWorld()); @@ -288,3 +327,21 @@ bool AEleriGameState::GetSeedData(int32 Index, FSeedBedSaveData& OutData) return false; } + +bool AEleriGameState::RequestNpcScheduleData(FGameplayTag NpcTag, FNpcScheduleSaveData& OutData) const +{ + if (!NpcTag.IsValid()) return false; + + // A linear scan, because one entry per scheduled NPC is a handful and a tag keyed map would only + // buy a hash for that. + for (const FNpcScheduleSaveData& NpcData : SaveData.NpcScheduleData) + { + if (NpcData.NpcTag == NpcTag) + { + OutData = NpcData; + return true; + } + } + + return false; +} diff --git a/Source/ProjectEleri/Private/EleriSaveGame.cpp b/Source/ProjectEleri/Private/EleriSaveGame.cpp index 661aed71..b08b1e79 100644 --- a/Source/ProjectEleri/Private/EleriSaveGame.cpp +++ b/Source/ProjectEleri/Private/EleriSaveGame.cpp @@ -8,6 +8,8 @@ #include "Components/StatForgeComponent.h" #include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" #include "ProjectEleri/GameObjects/EleriBaseActor.h" +#include "ProjectEleri/Ai/NpcScheduleComponent.h" +#include "ProjectEleri/GameObjects/BaseCharacter.h" void FSaveData::Initialize() { @@ -20,6 +22,7 @@ void FSaveData::Initialize() EventDataMap.Empty(); StatData.Empty(); GameEffects.Empty(); + NpcScheduleData.Empty(); } void FSaveData::SaveQuests(const TArray& Quests) { @@ -28,11 +31,6 @@ void FSaveData::SaveQuests(const TArray& Quests) { } } -void FSaveData::SetTimeOFDayData(int32 _Day, int32 _Month, int32 _Year, int32 _Minute, int32 _Hour) -{ - TimeOfDay = FTimeOfDayData(_Year, _Month, _Day, _Hour, _Minute); -} - void FSaveData::SetBookProgressionData(TArray _BookProgression) { BookProgression = _BookProgression; @@ -94,6 +92,39 @@ void FSaveData::CollectSaveableActorsData(const UWorld* World) } } +void FSaveData::CollectNpcScheduleData(const UWorld* World) +{ + if (!World) return; + + // Only characters can be scheduled, so the walk is narrowed to them instead of every actor holding + // a save component. Merged into the records that are already there rather than rebuilt, because an + // NPC spawned at runtime has no level placement to fall back on: if its spawner happens to have it + // out of the world at this exact moment, the last known state is the only thing that can put it + // back where the player left it when it is spawned in again. + for (TActorIterator It(World); It; ++It) + { + ABaseCharacter* Npc = *It; + if (!IsValid(Npc)) continue; + + const UNpcScheduleComponent* Schedule = Npc->GetScheduleComponent(); + if (Schedule == nullptr || !Schedule->NpcTag.IsValid()) continue; + + const FNpcScheduleSaveData NpcData = Schedule->PrepareSaveData(); + + FNpcScheduleSaveData* Existing = NpcScheduleData.FindByPredicate( + [&NpcData](const FNpcScheduleSaveData& Record) { return Record.NpcTag == NpcData.NpcTag; }); + + if (Existing != nullptr) + { + *Existing = NpcData; + } + else + { + NpcScheduleData.Add(NpcData); + } + } +} + void FSaveData::SetGameEventData(const UWorld* World) { if (!IsValid(World) || !IsValid(World->GetWorld()) || !IsValid(UGameplayStatics::GetGameInstance(World))) return; diff --git a/Source/ProjectEleri/Public/EleriGameState.h b/Source/ProjectEleri/Public/EleriGameState.h index 85b03977..b50c39f0 100644 --- a/Source/ProjectEleri/Public/EleriGameState.h +++ b/Source/ProjectEleri/Public/EleriGameState.h @@ -36,6 +36,10 @@ public: //Caches actor data but doesn't save it to disk void CacheActorData(AEleriBaseActor* EleriActor); bool RequestActorSaveData(const AEleriBaseActor* EleriActor, FActorSaveData& OutData) const; + + /** The schedule data cached for one NPC tag. False when nothing was ever cached for it, which is + * the normal case for an NPC that only appeared after the load had already been applied. */ + bool RequestNpcScheduleData(FGameplayTag NpcTag, FNpcScheduleSaveData& OutData) const; void CacheSeedData(int32 Index, const FSeedBedSaveData& Data); bool GetSeedData(int32 Index, FSeedBedSaveData& OutData); diff --git a/Source/ProjectEleri/Public/EleriSaveGame.h b/Source/ProjectEleri/Public/EleriSaveGame.h index 60bff798..797e28e1 100644 --- a/Source/ProjectEleri/Public/EleriSaveGame.h +++ b/Source/ProjectEleri/Public/EleriSaveGame.h @@ -14,6 +14,7 @@ #include "Data/StatForgeDefs.h" #include "ProjectEleri/Items/SeedBedActor.h" #include "ProjectEleri/System/TimeOfDayStruct.h" +#include "TimeOfDayManager.h" #include "EleriSaveGame.generated.h" USTRUCT() @@ -41,8 +42,10 @@ USTRUCT(BlueprintType) struct FSaveData { GENERATED_BODY() + /* The whole clock, not just the date: the schedules every NPC is driven by are a function of the + * minute of the day, so restoring anything less than the running time restores nothing useful. */ UPROPERTY() - FTimeOfDayData TimeOfDay; + FTimeOfDaySaveData TimeOfDaySave; UPROPERTY() TArray BookProgression; @@ -62,6 +65,10 @@ struct FSaveData { UPROPERTY() TMap ActorSaveData; + /* Where every scheduled NPC stood and what it was doing, one entry per NpcTag */ + UPROPERTY() + TArray NpcScheduleData; + UPROPERTY() TMap EventDataMap; @@ -76,7 +83,6 @@ struct FSaveData { void Initialize(); void SaveQuests(const TArray& Quests); - void SetTimeOFDayData(int32 _Day, int32 _Month, int32 _Year, int32 _Minute, int32 _Hour); void SetBookProgressionData(TArray _BookProgression); void SetInventoryDataForActor(AActor* Actor, UInventoryComponent* Inventory); void SetPlantedSeedData(int32 Index, const FSeedBedSaveData& SeedSaveData); @@ -84,6 +90,8 @@ struct FSaveData { void WaterAllPlantedSeeds(const FTimeOfDayData& WateredTime); void SetPlacedObjectsData(TArray InPlacedActors); void CollectSaveableActorsData(const UWorld* World); + /** Reads the schedule component of every NPC in the world, so a load can put all of them back */ + void CollectNpcScheduleData(const UWorld* World); void SetGameEventData(const UWorld* World); void SetPlayerData(const UWorld* World); void SetAbilitySystemData(const UWorld* World); diff --git a/Source/ProjectEleri/Public/Interface/SaveableObjectInterface.h b/Source/ProjectEleri/Public/Interface/SaveableObjectInterface.h index 36d67fba..40bdcb81 100644 --- a/Source/ProjectEleri/Public/Interface/SaveableObjectInterface.h +++ b/Source/ProjectEleri/Public/Interface/SaveableObjectInterface.h @@ -26,6 +26,46 @@ public: TMap SaveableTagData; }; +/** + * The part of an NPC's state that cannot be worked out again from the clock: where it stands, and what + * it was last told to do. + * + * The assignment is in here next to the transform because restoring the transform alone does not hold. + * NpcScheduleComponent::bHasAction starts false, so the first refresh after a load would see no action + * to compare against, treat the schedule's answer as new and push ScheduleActionChanged, which walks + * the NPC off the spot the load just put it on. + * + * Keyed by NpcTag rather than by a PersistentId, because a tag is authored and therefore survives a + * re-save of the level, and because it is the one thing a runtime spawned NPC also has. + */ +USTRUCT() +struct FNpcScheduleSaveData +{ + GENERATED_BODY() + + UPROPERTY() + FGameplayTag NpcTag; + + /* Where the NPC stood when the save was made */ + UPROPERTY() + FTransform Transform; + + /* An ENpcActivity, stored as its underlying type so this header stays free of the schedule asset */ + UPROPERTY() + uint8 Activity = 0; + + /* Ids rather than actors, because the bed or chair an NPC walks to may well be streamed out */ + UPROPERTY() + FGuid AnchorGuid; + + UPROPERTY() + FGuid RoamPathGuid; + + /* False when the NPC had not been given an assignment yet, in which case the ids above mean nothing */ + UPROPERTY() + bool bHasAction = false; +}; + // This class does not need to be modified. UINTERFACE(MinimalAPI) class USaveableObjectInterface : public UInterface diff --git a/Source/ProjectEleri/Public/TimeOfDayManager.h b/Source/ProjectEleri/Public/TimeOfDayManager.h index 6e8ddac3..2bbb19e0 100644 --- a/Source/ProjectEleri/Public/TimeOfDayManager.h +++ b/Source/ProjectEleri/Public/TimeOfDayManager.h @@ -90,6 +90,14 @@ public: FSoftObjectPath CurrentWeatherSoftObjectPath; UPROPERTY(SaveGame) FSoftObjectPath QueuedWeatherSoftObjectPath; + + /** True when this came from a real save. A stamp that was never written is all zeroes, which is + * what a save made before the clock was stored deserializes to, and applying that would drop the + * game back to midnight. A save taken exactly at midnight also reads as false here, in which case + * the level's own start time is kept, which is the harmless direction to be wrong in. */ + bool IsValidTime() const { + return FloatMinutes > 0.f || currentMinutes > 0 || currentHours > 0; + } }; USTRUCT(BlueprintType) diff --git a/Source/ProjectEleri/Settings/EleriGameSettings.h b/Source/ProjectEleri/Settings/EleriGameSettings.h index 41da2cea..01e5ede0 100644 --- a/Source/ProjectEleri/Settings/EleriGameSettings.h +++ b/Source/ProjectEleri/Settings/EleriGameSettings.h @@ -47,6 +47,11 @@ public: UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ai") TArray> NpcDataAssets; + + /** The day plan every NPC follows, keyed by the tag placed on its NpcScheduleComponent. Many NPCs + * can share one entry, which is the point of going through a tag at all. */ + UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ai|Schedule", meta = (ForceInlineRow, Categories = "NpcTags")) + TMap> NpcSchedules; UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ability System|Settings") TSoftObjectPtr GlobalSettingsDataSheet; diff --git a/Source/ProjectEleri/System/EleriGameplayTags.cpp b/Source/ProjectEleri/System/EleriGameplayTags.cpp index 0b325979..f777d368 100644 --- a/Source/ProjectEleri/System/EleriGameplayTags.cpp +++ b/Source/ProjectEleri/System/EleriGameplayTags.cpp @@ -9,6 +9,7 @@ namespace CharacterState namespace TimeOfDay { UE_DEFINE_GAMEPLAY_TAG(TimePassed, TEXT("TimeOfDay.TimePassed")) + UE_DEFINE_GAMEPLAY_TAG(ScheduleActionChanged, TEXT("TimeOfDay.ScheduleActionChanged")) } namespace Stats diff --git a/Source/ProjectEleri/System/EleriGameplayTags.h b/Source/ProjectEleri/System/EleriGameplayTags.h index cd246b0c..948ff34e 100644 --- a/Source/ProjectEleri/System/EleriGameplayTags.h +++ b/Source/ProjectEleri/System/EleriGameplayTags.h @@ -10,6 +10,10 @@ namespace CharacterState namespace TimeOfDay { PROJECTELERI_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(TimePassed) + + /** Sent with an FResolvedNpcAction payload to a scheduler driven StateTree whenever the NPC + * crosses into a different scheduled action. */ + PROJECTELERI_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(ScheduleActionChanged) } namespace Stats diff --git a/Source/ProjectEleri/System/Subsystem/ScheduleSubsystem.cpp b/Source/ProjectEleri/System/Subsystem/ScheduleSubsystem.cpp index 32733bd1..c4bee8d2 100644 --- a/Source/ProjectEleri/System/Subsystem/ScheduleSubsystem.cpp +++ b/Source/ProjectEleri/System/Subsystem/ScheduleSubsystem.cpp @@ -3,16 +3,162 @@ #include "ScheduleSubsystem.h" #include "ProjectEleri/Settings/EleriGameSettings.h" +#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h" +#include "Kismet/GameplayStatics.h" void UScheduleSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); - + const UEleriGameSettings* EleriGameSettings = GetDefault(); check(EleriGameSettings); - - // for (const TPair>& SchedulePair : EleriGameSettings->NpcSchedules) - // { - // SchedulePair - // } + + for (const TPair>& SchedulePair : EleriGameSettings->NpcSchedules) + { + if (!SchedulePair.Key.IsValid()) + { + UE_LOG(LogTemp, Warning, TEXT("NpcSchedules has an entry with no NpcTag, it is skipped.")); + continue; + } + + // Loaded up front because the first NPC to ask has to get an answer immediately, and because + // a schedule asset is tiny, just a handful of time windows. + if (UNpcScheduleAsset* ScheduleAsset = SchedulePair.Value.LoadSynchronous()) + { + NpcSchedules.Add(SchedulePair.Key, ScheduleAsset); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("NpcSchedules entry for %s points at an asset that could not be loaded."), + *SchedulePair.Key.ToString()); + } + } +} + +UNpcScheduleAsset* UScheduleSubsystem::GetScheduleForNpcTag(FGameplayTag NpcTag) const +{ + if (const TObjectPtr* Found = NpcSchedules.Find(NpcTag)) + { + return Found->Get(); + } + + return nullptr; +} + +bool UScheduleSubsystem::ResolveAction(FGameplayTag NpcTag, int32 MinutesOfDay, FResolvedNpcAction& OutAction) +{ + const UNpcScheduleAsset* Schedule = GetScheduleForNpcTag(NpcTag); + if (Schedule == nullptr) + { + return false; + } + + const FScheduledAction* Action = Schedule->FindActionForTime(MinutesOfDay); + if (Action == nullptr) + { + return false; + } + + FResolvedNpcAction Resolved; + Resolved.Activity = Action->Activity; + Resolved.AnchorGuid = Action->UsableObjectGuid; + Resolved.RoamPathGuid = Action->RoamPathGuid; + + if (AActor* Anchor = FindAnchorActor(Action->UsableObjectGuid)) + { + Resolved.Anchor = Anchor; + Resolved.bAnchorResolved = true; + Resolved.AnchorTransform = Anchor->GetActorTransform(); + + // Remembered while the anchor is still loaded, that is the only chance to see where it is. + AnchorTransformCache.Add(Action->UsableObjectGuid, Resolved.AnchorTransform); + } + else if (const FTransform* Cached = AnchorTransformCache.Find(Action->UsableObjectGuid)) + { + Resolved.AnchorTransform = *Cached; + } + + if (AActor* RoamPath = FindAnchorActor(Action->RoamPathGuid)) + { + Resolved.RoamPath = RoamPath; + } + + OutAction = Resolved; + return true; +} + +const FTransform* UScheduleSubsystem::GetCachedAnchorTransform(const FGuid& AnchorGuid) const +{ + return AnchorTransformCache.Find(AnchorGuid); +} + +AActor* UScheduleSubsystem::FindAnchorActor(const FGuid& AnchorGuid) +{ + if (!AnchorGuid.IsValid()) + { + return nullptr; + } + + if (TWeakObjectPtr* Known = LoadedAnchors.Find(AnchorGuid)) + { + if (AActor* Actor = Known->Get()) + { + return Actor; + } + + // The entry went stale, so the actor has streamed out. Drop it and let the transform cache + // serve the resolve until the player comes back and streams it in again. + LoadedAnchors.Remove(AnchorGuid); + return nullptr; + } + + // Nothing is known about this id yet, so the index has to be rebuilt, which means walking every + // loaded actor. That is rate limited, because a streamed out anchor would otherwise cost a full + // level walk on every single resolve for as long as the player stays away from it. + const UWorld* World = GetWorld(); + const float Now = World != nullptr ? World->GetTimeSeconds() : 0.0f; + if (Now - LastAnchorIndexTime < AnchorIndexCooldown) + { + return nullptr; + } + + RebuildAnchorIndex(); + LastAnchorIndexTime = Now; + + if (TWeakObjectPtr* Found = LoadedAnchors.Find(AnchorGuid)) + { + return Found->Get(); + } + + return nullptr; +} + +void UScheduleSubsystem::RebuildAnchorIndex() +{ + LoadedAnchors.Reset(); + + UWorld* World = GetWorld(); + if (World == nullptr) + { + return; + } + + // Only ever returns actors that are currently streamed in, which is exactly what is wanted here. + TArray IdentifiedActors; + UGameplayStatics::GetAllActorsWithInterface(World, UActorPersistentIdInterface::StaticClass(), IdentifiedActors); + + for (AActor* IdentifiedActor : IdentifiedActors) + { + const IActorPersistentIdInterface* Identified = Cast(IdentifiedActor); + if (Identified == nullptr) + { + continue; + } + + const FGuid Id = Identified->GetPersistentId(); + if (Id.IsValid()) + { + LoadedAnchors.Add(Id, IdentifiedActor); + } + } } diff --git a/Source/ProjectEleri/System/Subsystem/ScheduleSubsystem.h b/Source/ProjectEleri/System/Subsystem/ScheduleSubsystem.h index e6ce6a68..e5478854 100644 --- a/Source/ProjectEleri/System/Subsystem/ScheduleSubsystem.h +++ b/Source/ProjectEleri/System/Subsystem/ScheduleSubsystem.h @@ -3,9 +3,13 @@ #pragma once #include "CoreMinimal.h" +#include "GameplayTagContainer.h" #include "Subsystems/GameInstanceSubsystem.h" +#include "ProjectEleri/Ai/NpcScheduleAsset.h" #include "ScheduleSubsystem.generated.h" +class AActor; + USTRUCT(BlueprintType) struct FNpcData { @@ -14,15 +18,66 @@ struct FNpcData }; /** - * + * Owns the schedule assets every NPC is driven by, and the fallback transforms for anchors that + * stream out of the world. + * + * NPCs are marked non spatially loaded so they stay resident whatever the player does, but the beds, + * chairs and roam paths their schedules point at are ordinary streamed actors. This subsystem is what + * bridges the two, resolving an anchor Guid to a live actor when one is loaded and remembering where + * that actor was, so a schedule still resolves while the cell holding it is streamed out. + * + * It deliberately has no tick. A schedule is a pure function of (NpcTag, minute of day), so nothing + * here needs evaluating per frame, the per NPC components drive the evaluation instead. */ UCLASS() class PROJECTELERI_API UScheduleSubsystem : public UGameInstanceSubsystem { GENERATED_BODY() public: - + + /** The schedule asset an NPC tag maps to in UEleriGameSettings::NpcSchedules, or null when the + * tag has no entry. */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Schedule") + UNpcScheduleAsset* GetScheduleForNpcTag(FGameplayTag NpcTag) const; + + /** Works out what an NPC should be doing at MinutesOfDay. Returns false when the tag has no + * schedule, or when the clock sits in a gap between its windows, in which case OutAction is left + * untouched and the caller should keep the NPC on whatever it was already doing. */ + bool ResolveAction(FGameplayTag NpcTag, int32 MinutesOfDay, FResolvedNpcAction& OutAction); + + /** Where an anchor was the last time it could be seen, or null when it never was. */ + const FTransform* GetCachedAnchorTransform(const FGuid& AnchorGuid) const; + protected: - + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + + /** Resolves an anchor Guid to a loaded actor and refreshes the cached transform on the way out, so + * a later resolve still works once the anchor has streamed out. Null means no loaded actor claims + * that id, which is the normal case for furniture the player has walked away from. */ + AActor* FindAnchorActor(const FGuid& AnchorGuid); + + /** Walks the loaded anchors once and fills LoadedAnchors from their PersistentIds */ + void RebuildAnchorIndex(); + + /** Schedules keyed by the NPC tag that uses them, filled from the project settings on Initialize */ + UPROPERTY() + TMap> NpcSchedules; + + /** Where an anchor sat when it was last loaded, keyed by its PersistentId */ + UPROPERTY() + TMap AnchorTransformCache; + +private: + + /** Loaded anchors by PersistentId. Entries go stale on their own when the actor they point at + * streams out, and a stale entry is what asks for a rescan. */ + TMap> LoadedAnchors; + + /** World time of the last RebuildAnchorIndex, so a streamed out anchor cannot cost a full level + * walk on every resolve for as long as the player stays away from it. */ + float LastAnchorIndexTime = -1000.0f; + + /** How long an unresolved anchor has to wait before the index is rebuilt again */ + static constexpr float AnchorIndexCooldown = 1.0f; }; diff --git a/Source/ProjectEleri/System/TimeOfDayStruct.h b/Source/ProjectEleri/System/TimeOfDayStruct.h index cb8317e7..1307edb2 100644 --- a/Source/ProjectEleri/System/TimeOfDayStruct.h +++ b/Source/ProjectEleri/System/TimeOfDayStruct.h @@ -43,6 +43,14 @@ public: return (ToAbsoluteDays() * 24 * 60) + (Hour * 60) + Minute; } + /** The position inside one single day, 0 at midnight and 1439 at 23:59. + * Schedules are authored against this rather than ToAbsoluteMinutes(), because a schedule + * repeats every day while the absolute counter keeps growing and can even be negative for + * authored stamps that still carry a zero year. */ + int32 GetMinutesOfDay() const { + return (Hour * 60) + Minute; + } + /** A stamp that was never written, for example a save from before the stamp existed, is all zeroes */ bool IsValidTime() const { return Year > 0 || Month > 0 || Day > 0;