Initial npc schedule implementation
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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="")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<FScheduledAction> ScheduledActions;
|
||||
FGuid RoamPathGuid;
|
||||
|
||||
/** Higher wins when two windows overlap. Equal priorities fall back to authoring order. */
|
||||
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()
|
||||
class PROJECTELERI_API UNpcScheduleAsset : public UDataAsset
|
||||
@@ -48,4 +72,48 @@ public:
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly)
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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<UNpcScheduleAsset> 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<FScheduleInstance> 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<ATimeOfDayManager> CachedTimeOfDayManager;
|
||||
TWeakObjectPtr<AActor> CachedViewTarget;
|
||||
|
||||
/** False until the freeze state has been applied once */
|
||||
bool bFreezeStateApplied = false;
|
||||
|
||||
bool bFrozen = false;
|
||||
};
|
||||
@@ -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()
|
||||
{
|
||||
@@ -36,6 +37,15 @@ ABaseCharacter::ABaseCharacter()
|
||||
StateTreeComponent = CreateDefaultSubobject<UStateTreeAIComponent>("State Tree Component");
|
||||
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->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<UFaceExpressionDataAsset>(Object);
|
||||
|
||||
@@ -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,12 +35,24 @@ 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<UStateTree> 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;
|
||||
|
||||
protected:
|
||||
@@ -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<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")
|
||||
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();
|
||||
|
||||
@@ -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<USceneComponent>("Root");
|
||||
SetRootComponent(RootComp);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<AEleriGameState>(GetWorld()->GetGameState())) {
|
||||
EleriGameState->PropagateSaveData(Cast<UEleriSaveGame>(LoadedGameData));
|
||||
SaveGameObject = 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->PropagateSaveData(SaveGameObject);
|
||||
}
|
||||
|
||||
void UEleriGameInstance::SaveGame()
|
||||
@@ -60,9 +68,12 @@ void UEleriGameInstance::OnSaveGame(const FString& SlotName, const int32 UserInd
|
||||
}
|
||||
|
||||
void UEleriGameInstance::OnGameLoaded() {
|
||||
if (GetWorld()) {
|
||||
if (AEleriGameState* EleriGameState = Cast<AEleriGameState>(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);
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
// 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
|
||||
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<ATimeOfDayManager>(
|
||||
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<ABookOfAlchemyManager>(
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
{
|
||||
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) {
|
||||
if (!IsValid(World) || !IsValid(World->GetWorld()) || !IsValid(UGameplayStatics::GetGameInstance(World)))
|
||||
return;
|
||||
|
||||
@@ -37,6 +37,10 @@ public:
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<FBookProgressionInfo> BookProgression;
|
||||
@@ -62,6 +65,10 @@ struct FSaveData {
|
||||
UPROPERTY()
|
||||
TMap<FGuid, FActorSaveData> ActorSaveData;
|
||||
|
||||
/* Where every scheduled NPC stood and what it was doing, one entry per NpcTag */
|
||||
UPROPERTY()
|
||||
TArray<FNpcScheduleSaveData> NpcScheduleData;
|
||||
|
||||
UPROPERTY()
|
||||
TMap<FSoftObjectPath, FEventData> EventDataMap;
|
||||
|
||||
@@ -76,7 +83,6 @@ struct FSaveData {
|
||||
|
||||
void Initialize();
|
||||
void SaveQuests(const TArray<FQuestItem>& Quests);
|
||||
void SetTimeOFDayData(int32 _Day, int32 _Month, int32 _Year, int32 _Minute, int32 _Hour);
|
||||
void SetBookProgressionData(TArray<FBookProgressionInfo> _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<FPlaceableActorInfo> 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);
|
||||
|
||||
@@ -26,6 +26,46 @@ public:
|
||||
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.
|
||||
UINTERFACE(MinimalAPI)
|
||||
class USaveableObjectInterface : public UInterface
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -48,6 +48,11 @@ public:
|
||||
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ai")
|
||||
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")
|
||||
TSoftObjectPtr<UDataTable> GlobalSettingsDataSheet;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
#include "ScheduleSubsystem.h"
|
||||
#include "ProjectEleri/Settings/EleriGameSettings.h"
|
||||
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
|
||||
#include "Kismet/GameplayStatics.h"
|
||||
|
||||
void UScheduleSubsystem::Initialize(FSubsystemCollectionBase& Collection)
|
||||
{
|
||||
@@ -11,8 +13,152 @@ void UScheduleSubsystem::Initialize(FSubsystemCollectionBase& Collection)
|
||||
const UEleriGameSettings* EleriGameSettings = GetDefault<UEleriGameSettings>();
|
||||
check(EleriGameSettings);
|
||||
|
||||
// for (const TPair<FGameplayTag, TSoftObjectPtr<UNpcScheduleAsset>>& SchedulePair : EleriGameSettings->NpcSchedules)
|
||||
// {
|
||||
// SchedulePair
|
||||
// }
|
||||
for (const TPair<FGameplayTag, TSoftObjectPtr<UNpcScheduleAsset>>& 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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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()
|
||||
class PROJECTELERI_API UScheduleSubsystem : public UGameInstanceSubsystem
|
||||
@@ -22,7 +35,49 @@ 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<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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user