Initial npc schedule implementation

This commit is contained in:
2026-09-19 13:53:28 +02:00
parent 62c8e8e292
commit ff35738d78
23 changed files with 1026 additions and 57 deletions

View File

@@ -197,6 +197,9 @@ ItemSeedCarouselClass=None
RemovableStaticMeshClass=/Script/Engine.BlueprintGeneratedClass'/Game/Prefabs/BP_RemovableStaticMeshActor.BP_RemovableStaticMeshActor_C' RemovableStaticMeshClass=/Script/Engine.BlueprintGeneratedClass'/Game/Prefabs/BP_RemovableStaticMeshActor.BP_RemovableStaticMeshActor_C'
MovementDisableEffectClass=/Script/Engine.BlueprintGeneratedClass'/Game/Blueprints/GameEffects/GSE_MovementDisabled.GSE_MovementDisabled_C' MovementDisableEffectClass=/Script/Engine.BlueprintGeneratedClass'/Game/Blueprints/GameEffects/GSE_MovementDisabled.GSE_MovementDisabled_C'
SpriteShadowMaterial=/Game/Materials/Sprite/MI_SpriteShadow_Inst.MI_SpriteShadow_Inst 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] [/Script/InteractionSystem.InteractionSettings]
DefaultInteractionWidgetClass=/Script/UMG.WidgetBlueprintGeneratedClass'/Game/UI/InteractionUI/WBP_Eleri_Interaction.WBP_Eleri_Interaction_C' DefaultInteractionWidgetClass=/Script/UMG.WidgetBlueprintGeneratedClass'/Game/UI/InteractionUI/WBP_Eleri_Interaction.WBP_Eleri_Interaction_C'

View File

@@ -44,6 +44,7 @@ NetIndexFirstBitSegment=16
+GameplayTagList=(Tag="Items.Crystals.Fire",DevComment="") +GameplayTagList=(Tag="Items.Crystals.Fire",DevComment="")
+GameplayTagList=(Tag="Items.Crystals.Water",DevComment="") +GameplayTagList=(Tag="Items.Crystals.Water",DevComment="")
+GameplayTagList=(Tag="Items.Gold",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",DevComment="")
+GameplayTagList=(Tag="SaveGameTags.Alchemy.TableLevel",DevComment="") +GameplayTagList=(Tag="SaveGameTags.Alchemy.TableLevel",DevComment="")
+GameplayTagList=(Tag="SaveGameTags.Bazar.Table.InventorySpace",DevComment="") +GameplayTagList=(Tag="SaveGameTags.Bazar.Table.InventorySpace",DevComment="")

View File

@@ -3,3 +3,54 @@
#include "NpcScheduleAsset.h" #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;
}

View File

@@ -5,14 +5,29 @@
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "Engine/DataAsset.h" #include "Engine/DataAsset.h"
#include "ProjectEleri/System/TimeOfDayStruct.h" #include "ProjectEleri/System/TimeOfDayStruct.h"
#include "ProjectEleri/GameObjects/BasicSplineActor.h"
#include "NpcScheduleAsset.generated.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) USTRUCT(BlueprintType)
struct FScheduledAction { struct FScheduledAction {
GENERATED_BODY(); GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly) UPROPERTY(EditAnywhere, BlueprintReadOnly)
FTimeOfDayData StartTime; FTimeOfDayData StartTime;
@@ -20,24 +35,33 @@ struct FScheduledAction {
UPROPERTY(EditAnywhere, BlueprintReadOnly) UPROPERTY(EditAnywhere, BlueprintReadOnly)
FTimeOfDayData EndTime; 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) UPROPERTY(EditAnywhere, BlueprintReadOnly)
FGuid UsableObjectGuid; FGuid UsableObjectGuid;
}; /** 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. */
USTRUCT(BlueprintType)
struct FScheduleInstance {
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly) UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FScheduledAction> ScheduledActions; FGuid RoamPathGuid;
/** Higher wins when two windows overlap. Equal priorities fall back to authoring order. */
UPROPERTY(EditAnywhere, BlueprintReadOnly) UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<ABasicSplineActor*> 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() UCLASS()
class PROJECTELERI_API UNpcScheduleAsset : public UDataAsset class PROJECTELERI_API UNpcScheduleAsset : public UDataAsset
@@ -48,4 +72,48 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadOnly) UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FScheduledAction> ScheduledActions; TArray<FScheduledAction> 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<AActor> 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<AActor> 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;
}; };

View File

@@ -1,3 +1,320 @@
// Fill out your copyright notice in the Description page of Project Settings. // Fill out your copyright notice in the Description page of Project Settings.
#include "NpcScheduleComponent.h" #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<uint8>(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<ENpcActivity>(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<FResolvedNpcAction>();
Payload.GetMutable<FResolvedNpcAction>() = 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<UPawnMovementComponent>();
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<ABaseCharacter>(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<ATimeOfDayManager>(
UGameplayStatics::GetActorOfClass(this, ATimeOfDayManager::StaticClass()));
return CachedTimeOfDayManager.Get();
}
UStateTreeComponent* UNpcScheduleComponent::GetStateTreeComponent()
{
const AActor* Owner = GetOwner();
return Owner != nullptr ? Owner->FindComponentByClass<UStateTreeComponent>() : 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<UScheduleSubsystem>() : nullptr;
}

View File

@@ -4,24 +4,102 @@
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "Components/ActorComponent.h" #include "Components/ActorComponent.h"
#include "StructUtils/InstancedStruct.h" #include "GameplayTagContainer.h"
#include "ProjectEleri/Ai/NpcScheduleAsset.h" #include "ProjectEleri/Ai/NpcScheduleAsset.h"
#include "ProjectEleri/Public/Interface/SaveableObjectInterface.h"
#include "NpcScheduleComponent.generated.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) UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent), BlueprintType, Blueprintable)
class PROJECTELERI_API UNpcScheduleComponent : public UActorComponent class PROJECTELERI_API UNpcScheduleComponent : public UActorComponent
{ {
GENERATED_BODY() 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: protected:
UPROPERTY(EditAnywhere) /** The manager, cached because GetActorOfClass walks every actor in the level and this is asked for
TObjectPtr<UNpcScheduleAsset> ScheduleAsset; * twice a second by every NPC. */
ATimeOfDayManager* FindTimeOfDayManager();
UPROPERTY(EditAnywhere) /** The pawn distances are measured from, cached for the same reason as the manager. */
TInstancedStruct<FScheduleInstance> Schedule; 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<ATimeOfDayManager> CachedTimeOfDayManager;
TWeakObjectPtr<AActor> CachedViewTarget;
/** False until the freeze state has been applied once */
bool bFreezeStateApplied = false;
bool bFrozen = false;
}; };

View File

@@ -12,6 +12,7 @@
#include "Kismet/GameplayStatics.h" #include "Kismet/GameplayStatics.h"
#include "ProjectEleri/System/TimeOfDayStruct.h" #include "ProjectEleri/System/TimeOfDayStruct.h"
#include "ProjectEleri/System/EleriGameplayTags.h" #include "ProjectEleri/System/EleriGameplayTags.h"
#include "ProjectEleri/Ai/NpcScheduleComponent.h"
ABaseCharacter::ABaseCharacter() ABaseCharacter::ABaseCharacter()
{ {
@@ -36,6 +37,15 @@ ABaseCharacter::ABaseCharacter()
StateTreeComponent = CreateDefaultSubobject<UStateTreeAIComponent>("State Tree Component"); StateTreeComponent = CreateDefaultSubobject<UStateTreeAIComponent>("State Tree Component");
StateTreeComponent->SetStartLogicAutomatically(false); StateTreeComponent->SetStartLogicAutomatically(false);
ScheduleComponent = CreateDefaultSubobject<UNpcScheduleComponent>("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<UInteractionWidgetComponent>(TEXT("WidgetComponent")); WidgetComponent = CreateDefaultSubobject<UInteractionWidgetComponent>(TEXT("WidgetComponent"));
WidgetComponent->SetupAttachment(RootComponent); WidgetComponent->SetupAttachment(RootComponent);
} }
@@ -50,17 +60,12 @@ void ABaseCharacter::BeginPlay()
{ {
FaceExpressionData.LoadAsync(FLoadSoftObjectPathAsyncDelegate::CreateUObject(this, &ABaseCharacter::OnFaceDataLoaded)); FaceExpressionData.LoadAsync(FLoadSoftObjectPathAsyncDelegate::CreateUObject(this, &ABaseCharacter::OnFaceDataLoaded));
} }
if (!CharacterStateTree.IsNull()) { // A schedule component holds the logic back and starts it on this character's first unfreeze, so
if (CharacterStateTree.IsValid()) { // there is deliberately nothing to start here. The fallback stays for anything the component has
StateTreeComponent->SetStateTree(CharacterStateTree.Get()); // been taken off.
StateTreeComponent->StartLogic(); if (ScheduleComponent == nullptr)
} {
else { StartCharacterLogic();
CharacterStateTree.LoadAsync(FLoadSoftObjectPathAsyncDelegate::CreateLambda([&](const FSoftObjectPath& Path, UObject* Obj) {
StateTreeComponent->SetStateTree(CharacterStateTree.Get());
StateTreeComponent->StartLogic();
}));
}
} }
if (!CharacterStateTree.IsNull()) { if (!CharacterStateTree.IsNull()) {
@@ -85,6 +90,39 @@ void ABaseCharacter::OnMinutesAdded(FTimeOfDayData TimeOfDay) {
StateTreeComponent->SendStateTreeEvent(TimeOfDay::TimePassed, Payload, FName()); 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) void ABaseCharacter::OnFaceDataLoaded(const FSoftObjectPath &Path, UObject *Object)
{ {
FaceExpressionData = Cast<UFaceExpressionDataAsset>(Object); FaceExpressionData = Cast<UFaceExpressionDataAsset>(Object);

View File

@@ -17,6 +17,7 @@ class UDialogueComponent;
class UStateTreeAIComponent; class UStateTreeAIComponent;
class UCapsuleComponent; class UCapsuleComponent;
class USkeletalMeshComponent; class USkeletalMeshComponent;
class UNpcScheduleComponent;
UCLASS(BlueprintType) UCLASS(BlueprintType)
class PROJECTELERI_API ABaseCharacter : public APawn, public IInteractableActorInterface, public INpcSpeakerDataInterface class PROJECTELERI_API ABaseCharacter : public APawn, public IInteractableActorInterface, public INpcSpeakerDataInterface
@@ -34,12 +35,24 @@ public:
UFUNCTION(BlueprintCallable) UFUNCTION(BlueprintCallable)
void ChangeFaceAnimation(EDialogueEmotion Emotion); 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) UPROPERTY(Category = Character, EditDefaultsOnly, BlueprintReadOnly)
TSoftObjectPtr<UStateTree> CharacterStateTree; TSoftObjectPtr<UStateTree> CharacterStateTree;
UFUNCTION(BlueprintCallable, BlueprintPure) UFUNCTION(BlueprintCallable, BlueprintPure)
UCapsuleComponent* GetCapsuleComponent() { return Capsule; } 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; virtual void ToggleInteractableUI_Implementation(bool bActive) override;
protected: protected:
@@ -51,9 +64,13 @@ protected:
UPROPERTY(Category = Character, EditDefaultsOnly, BlueprintReadOnly, meta = (AllowPrivateAccess = "true")) UPROPERTY(Category = Character, EditDefaultsOnly, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
bool bHasFaceAnimation = false; bool bHasFaceAnimation = false;
UPROPERTY(EditAnywhere, BlueprintReadOnly) UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components")
TObjectPtr<UStateTreeAIComponent> StateTreeComponent; TObjectPtr<UStateTreeAIComponent> StateTreeComponent;
/** Drives this NPC from its schedule and freezes it while the player is far away */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components")
TObjectPtr<UNpcScheduleComponent> ScheduleComponent;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components") UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components")
UCapsuleComponent* Capsule; UCapsuleComponent* Capsule;
@@ -73,6 +90,10 @@ protected:
UFUNCTION() UFUNCTION()
void OnMinutesAdded(FTimeOfDayData TimeOfDay); void OnMinutesAdded(FTimeOfDayData TimeOfDay);
/** Completion of the deferred StateTree load started by StartCharacterLogic */
UFUNCTION()
void OnStateTreeAsyncLoaded(const FSoftObjectPath& Path, UObject* Object);
UFUNCTION() UFUNCTION()
void OnFaceDataLoaded(const FSoftObjectPath& Path, UObject* Object); void OnFaceDataLoaded(const FSoftObjectPath& Path, UObject* Object);
void SetupFaceAnimationPlayer(); void SetupFaceAnimationPlayer();

View File

@@ -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. // 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; PrimaryActorTick.bCanEverTick = false;
PersistentId = FGuid::NewGuid();
USceneComponent* RootComp = CreateDefaultSubobject<USceneComponent>("Root"); USceneComponent* RootComp = CreateDefaultSubobject<USceneComponent>("Root");
SetRootComponent(RootComp); SetRootComponent(RootComp);

View File

@@ -5,11 +5,12 @@
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "GameFramework/Actor.h" #include "GameFramework/Actor.h"
#include "Components/SplineComponent.h" #include "Components/SplineComponent.h"
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
#include "BasicSplineActor.generated.h" #include "BasicSplineActor.generated.h"
UCLASS() UCLASS()
class PROJECTELERI_API ABasicSplineActor : public AActor class PROJECTELERI_API ABasicSplineActor : public AActor, public IActorPersistentIdInterface
{ {
GENERATED_BODY() GENERATED_BODY()
@@ -19,4 +20,15 @@ public:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly) UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
USplineComponent* Spline; 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;
}; };

View File

@@ -26,12 +26,20 @@ void UEleriGameInstance::LoadGame()
void UEleriGameInstance::OnLoadGame(const FString& SlotName, const int32 UserIndex, USaveGame* LoadedGameData) void UEleriGameInstance::OnLoadGame(const FString& SlotName, const int32 UserIndex, USaveGame* LoadedGameData)
{ {
if (LoadedGameData && GetWorld()) { SaveGameObject = Cast<UEleriSaveGame>(LoadedGameData);
if (AEleriGameState* EleriGameState = Cast<AEleriGameState>(GetWorld()->GetGameState())) {
EleriGameState->PropagateSaveData(Cast<UEleriSaveGame>(LoadedGameData)); AEleriGameState* EleriGameState = GetWorld() ? Cast<AEleriGameState>(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->OnPropagateSaveDataComplete.AddUniqueDynamic(this, &UEleriGameInstance::OnGameLoaded);
} EleriGameState->PropagateSaveData(SaveGameObject);
}
} }
void UEleriGameInstance::SaveGame() void UEleriGameInstance::SaveGame()
@@ -60,9 +68,12 @@ void UEleriGameInstance::OnSaveGame(const FString& SlotName, const int32 UserInd
} }
void UEleriGameInstance::OnGameLoaded() { void UEleriGameInstance::OnGameLoaded() {
if (GetWorld()) {
if (AEleriGameState* EleriGameState = Cast<AEleriGameState>(GetWorld()->GetGameState())) { if (AEleriGameState* EleriGameState = Cast<AEleriGameState>(GetWorld()->GetGameState())) {
EleriGameState->OnPropagateSaveDataComplete.RemoveDynamic(this, &UEleriGameInstance::OnGameLoaded); 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);
}

View File

@@ -10,6 +10,8 @@
#include "Components/StatForgeComponent.h" #include "Components/StatForgeComponent.h"
#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" #include "ProjectEleri/System/MainBlueprintFunctionLibrary.h"
#include "ProjectEleri/GameObjects/EleriBaseActor.h" #include "ProjectEleri/GameObjects/EleriBaseActor.h"
#include "ProjectEleri/Ai/NpcScheduleComponent.h"
#include "ProjectEleri/GameObjects/BaseCharacter.h"
AEleriGameState* AEleriGameState::Get(const UObject* WorldContext) AEleriGameState* AEleriGameState::Get(const UObject* WorldContext)
{ {
@@ -112,6 +114,20 @@ void AEleriGameState::PropagateSaveData(UEleriSaveGame* InSaveData)
SaveData = InSaveData->SaveData; 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<ATimeOfDayManager>(
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<AActor*> OutActors; TArray<AActor*> OutActors;
// Load all inventories // 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<ABaseCharacter> 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 //Game event data
if (GetWorld()->GetGameInstance()) if (GetWorld()->GetGameInstance())
{ {
@@ -185,6 +218,10 @@ void AEleriGameState::PropagateSaveData(UEleriSaveGame* InSaveData)
//Seed delete and spawn should always go last //Seed delete and spawn should always go last
PropagateAllSeedData(); 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() const FSaveData& AEleriGameState::PrepareSaveData()
@@ -193,8 +230,7 @@ const FSaveData& AEleriGameState::PrepareSaveData()
ATimeOfDayManager* TimeOfDay = Cast<ATimeOfDayManager>( ATimeOfDayManager* TimeOfDay = Cast<ATimeOfDayManager>(
UGameplayStatics::GetActorOfClass(GetWorld(), ATimeOfDayManager::StaticClass())); UGameplayStatics::GetActorOfClass(GetWorld(), ATimeOfDayManager::StaticClass()));
check(TimeOfDay); check(TimeOfDay);
SaveData.SetTimeOFDayData(TimeOfDay->GetDaysPassed(), TimeOfDay->GetMonth(), TimeOfDay->GetYear(), SaveData.TimeOfDaySave = TimeOfDay->GetSaveData();
TimeOfDay->GetExactMinutes(), TimeOfDay->GetCurrentHours());
//SAVE BOOK //SAVE BOOK
const ABookOfAlchemyManager* Book = Cast<ABookOfAlchemyManager>( const ABookOfAlchemyManager* Book = Cast<ABookOfAlchemyManager>(
@@ -237,6 +273,9 @@ const FSaveData& AEleriGameState::PrepareSaveData()
//SAVE ACTORS //SAVE ACTORS
SaveData.CollectSaveableActorsData(GetWorld()); SaveData.CollectSaveableActorsData(GetWorld());
//SAVE NPC SCHEDULES
SaveData.CollectNpcScheduleData(GetWorld());
//SAVE GAME EVENTS //SAVE GAME EVENTS
SaveData.SetGameEventData(GetWorld()); SaveData.SetGameEventData(GetWorld());
@@ -288,3 +327,21 @@ bool AEleriGameState::GetSeedData(int32 Index, FSeedBedSaveData& OutData)
return false; 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;
}

View File

@@ -8,6 +8,8 @@
#include "Components/StatForgeComponent.h" #include "Components/StatForgeComponent.h"
#include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" #include "ProjectEleri/System/MainBlueprintFunctionLibrary.h"
#include "ProjectEleri/GameObjects/EleriBaseActor.h" #include "ProjectEleri/GameObjects/EleriBaseActor.h"
#include "ProjectEleri/Ai/NpcScheduleComponent.h"
#include "ProjectEleri/GameObjects/BaseCharacter.h"
void FSaveData::Initialize() void FSaveData::Initialize()
{ {
@@ -20,6 +22,7 @@ void FSaveData::Initialize()
EventDataMap.Empty(); EventDataMap.Empty();
StatData.Empty(); StatData.Empty();
GameEffects.Empty(); GameEffects.Empty();
NpcScheduleData.Empty();
} }
void FSaveData::SaveQuests(const TArray<FQuestItem>& Quests) { void FSaveData::SaveQuests(const TArray<FQuestItem>& Quests) {
@@ -28,11 +31,6 @@ void FSaveData::SaveQuests(const TArray<FQuestItem>& 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<FBookProgressionInfo> _BookProgression) void FSaveData::SetBookProgressionData(TArray<FBookProgressionInfo> _BookProgression)
{ {
BookProgression = _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<ABaseCharacter> 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) { void FSaveData::SetGameEventData(const UWorld* World) {
if (!IsValid(World) || !IsValid(World->GetWorld()) || !IsValid(UGameplayStatics::GetGameInstance(World))) if (!IsValid(World) || !IsValid(World->GetWorld()) || !IsValid(UGameplayStatics::GetGameInstance(World)))
return; return;

View File

@@ -37,6 +37,10 @@ public:
void CacheActorData(AEleriBaseActor* EleriActor); void CacheActorData(AEleriBaseActor* EleriActor);
bool RequestActorSaveData(const AEleriBaseActor* EleriActor, FActorSaveData& OutData) const; 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); void CacheSeedData(int32 Index, const FSeedBedSaveData& Data);
bool GetSeedData(int32 Index, FSeedBedSaveData& OutData); bool GetSeedData(int32 Index, FSeedBedSaveData& OutData);

View File

@@ -14,6 +14,7 @@
#include "Data/StatForgeDefs.h" #include "Data/StatForgeDefs.h"
#include "ProjectEleri/Items/SeedBedActor.h" #include "ProjectEleri/Items/SeedBedActor.h"
#include "ProjectEleri/System/TimeOfDayStruct.h" #include "ProjectEleri/System/TimeOfDayStruct.h"
#include "TimeOfDayManager.h"
#include "EleriSaveGame.generated.h" #include "EleriSaveGame.generated.h"
USTRUCT() USTRUCT()
@@ -41,8 +42,10 @@ USTRUCT(BlueprintType)
struct FSaveData { struct FSaveData {
GENERATED_BODY() 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() UPROPERTY()
FTimeOfDayData TimeOfDay; FTimeOfDaySaveData TimeOfDaySave;
UPROPERTY() UPROPERTY()
TArray<FBookProgressionInfo> BookProgression; TArray<FBookProgressionInfo> BookProgression;
@@ -62,6 +65,10 @@ struct FSaveData {
UPROPERTY() UPROPERTY()
TMap<FGuid, FActorSaveData> ActorSaveData; TMap<FGuid, FActorSaveData> ActorSaveData;
/* Where every scheduled NPC stood and what it was doing, one entry per NpcTag */
UPROPERTY()
TArray<FNpcScheduleSaveData> NpcScheduleData;
UPROPERTY() UPROPERTY()
TMap<FSoftObjectPath, FEventData> EventDataMap; TMap<FSoftObjectPath, FEventData> EventDataMap;
@@ -76,7 +83,6 @@ struct FSaveData {
void Initialize(); void Initialize();
void SaveQuests(const TArray<FQuestItem>& Quests); void SaveQuests(const TArray<FQuestItem>& Quests);
void SetTimeOFDayData(int32 _Day, int32 _Month, int32 _Year, int32 _Minute, int32 _Hour);
void SetBookProgressionData(TArray<FBookProgressionInfo> _BookProgression); void SetBookProgressionData(TArray<FBookProgressionInfo> _BookProgression);
void SetInventoryDataForActor(AActor* Actor, UInventoryComponent* Inventory); void SetInventoryDataForActor(AActor* Actor, UInventoryComponent* Inventory);
void SetPlantedSeedData(int32 Index, const FSeedBedSaveData& SeedSaveData); void SetPlantedSeedData(int32 Index, const FSeedBedSaveData& SeedSaveData);
@@ -84,6 +90,8 @@ struct FSaveData {
void WaterAllPlantedSeeds(const FTimeOfDayData& WateredTime); void WaterAllPlantedSeeds(const FTimeOfDayData& WateredTime);
void SetPlacedObjectsData(TArray<FPlaceableActorInfo> InPlacedActors); void SetPlacedObjectsData(TArray<FPlaceableActorInfo> InPlacedActors);
void CollectSaveableActorsData(const UWorld* World); 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 SetGameEventData(const UWorld* World);
void SetPlayerData(const UWorld* World); void SetPlayerData(const UWorld* World);
void SetAbilitySystemData(const UWorld* World); void SetAbilitySystemData(const UWorld* World);

View File

@@ -26,6 +26,46 @@ public:
TMap<FGameplayTag, int32> SaveableTagData; TMap<FGameplayTag, int32> 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. // This class does not need to be modified.
UINTERFACE(MinimalAPI) UINTERFACE(MinimalAPI)
class USaveableObjectInterface : public UInterface class USaveableObjectInterface : public UInterface

View File

@@ -90,6 +90,14 @@ public:
FSoftObjectPath CurrentWeatherSoftObjectPath; FSoftObjectPath CurrentWeatherSoftObjectPath;
UPROPERTY(SaveGame) UPROPERTY(SaveGame)
FSoftObjectPath QueuedWeatherSoftObjectPath; 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) USTRUCT(BlueprintType)

View File

@@ -48,6 +48,11 @@ public:
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ai") UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ai")
TArray<TSoftObjectPtr<UNpcDataAsset>> NpcDataAssets; TArray<TSoftObjectPtr<UNpcDataAsset>> 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<FGameplayTag, TSoftObjectPtr<UNpcScheduleAsset>> NpcSchedules;
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ability System|Settings") UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ability System|Settings")
TSoftObjectPtr<UDataTable> GlobalSettingsDataSheet; TSoftObjectPtr<UDataTable> GlobalSettingsDataSheet;

View File

@@ -9,6 +9,7 @@ namespace CharacterState
namespace TimeOfDay namespace TimeOfDay
{ {
UE_DEFINE_GAMEPLAY_TAG(TimePassed, TEXT("TimeOfDay.TimePassed")) UE_DEFINE_GAMEPLAY_TAG(TimePassed, TEXT("TimeOfDay.TimePassed"))
UE_DEFINE_GAMEPLAY_TAG(ScheduleActionChanged, TEXT("TimeOfDay.ScheduleActionChanged"))
} }
namespace Stats namespace Stats

View File

@@ -10,6 +10,10 @@ namespace CharacterState
namespace TimeOfDay namespace TimeOfDay
{ {
PROJECTELERI_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(TimePassed) 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 namespace Stats

View File

@@ -3,6 +3,8 @@
#include "ScheduleSubsystem.h" #include "ScheduleSubsystem.h"
#include "ProjectEleri/Settings/EleriGameSettings.h" #include "ProjectEleri/Settings/EleriGameSettings.h"
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
#include "Kismet/GameplayStatics.h"
void UScheduleSubsystem::Initialize(FSubsystemCollectionBase& Collection) void UScheduleSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{ {
@@ -11,8 +13,152 @@ void UScheduleSubsystem::Initialize(FSubsystemCollectionBase& Collection)
const UEleriGameSettings* EleriGameSettings = GetDefault<UEleriGameSettings>(); const UEleriGameSettings* EleriGameSettings = GetDefault<UEleriGameSettings>();
check(EleriGameSettings); check(EleriGameSettings);
// for (const TPair<FGameplayTag, TSoftObjectPtr<UNpcScheduleAsset>>& SchedulePair : EleriGameSettings->NpcSchedules) for (const TPair<FGameplayTag, TSoftObjectPtr<UNpcScheduleAsset>>& SchedulePair : EleriGameSettings->NpcSchedules)
// { {
// SchedulePair 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<UNpcScheduleAsset>* 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<AActor>* 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<AActor>* 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<AActor*> IdentifiedActors;
UGameplayStatics::GetAllActorsWithInterface(World, UActorPersistentIdInterface::StaticClass(), IdentifiedActors);
for (AActor* IdentifiedActor : IdentifiedActors)
{
const IActorPersistentIdInterface* Identified = Cast<IActorPersistentIdInterface>(IdentifiedActor);
if (Identified == nullptr)
{
continue;
}
const FGuid Id = Identified->GetPersistentId();
if (Id.IsValid())
{
LoadedAnchors.Add(Id, IdentifiedActor);
}
}
} }

View File

@@ -3,9 +3,13 @@
#pragma once #pragma once
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "Subsystems/GameInstanceSubsystem.h" #include "Subsystems/GameInstanceSubsystem.h"
#include "ProjectEleri/Ai/NpcScheduleAsset.h"
#include "ScheduleSubsystem.generated.h" #include "ScheduleSubsystem.generated.h"
class AActor;
USTRUCT(BlueprintType) USTRUCT(BlueprintType)
struct FNpcData struct FNpcData
{ {
@@ -14,7 +18,16 @@ 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() UCLASS()
class PROJECTELERI_API UScheduleSubsystem : public UGameInstanceSubsystem class PROJECTELERI_API UScheduleSubsystem : public UGameInstanceSubsystem
@@ -22,7 +35,49 @@ class PROJECTELERI_API UScheduleSubsystem : public UGameInstanceSubsystem
GENERATED_BODY() GENERATED_BODY()
public: 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: protected:
virtual void Initialize(FSubsystemCollectionBase& Collection) override; 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<FGameplayTag, TObjectPtr<UNpcScheduleAsset>> NpcSchedules;
/** Where an anchor sat when it was last loaded, keyed by its PersistentId */
UPROPERTY()
TMap<FGuid, FTransform> 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<FGuid, TWeakObjectPtr<AActor>> 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;
}; };

View File

@@ -43,6 +43,14 @@ public:
return (ToAbsoluteDays() * 24 * 60) + (Hour * 60) + Minute; 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 */ /** A stamp that was never written, for example a save from before the stamp existed, is all zeroes */
bool IsValidTime() const { bool IsValidTime() const {
return Year > 0 || Month > 0 || Day > 0; return Year > 0 || Month > 0 || Day > 0;