Massive changes to the npc system

This commit is contained in:
2026-09-20 14:22:08 +02:00
parent 1fe94cfa7a
commit 5d61de068c
58 changed files with 3752 additions and 188 deletions

View File

@@ -344,6 +344,7 @@ r.FidelityFX.FSR2.UseSSRExperimentalDenoiser=True
[/Script/NavigationSystem.NavigationSystemV1]
bAutoCreateNavigationData=False
bAllowClientSideNavigation=True
DataGatheringMode=Lazy
[/Script/Engine.UserInterfaceSettings]
RenderFocusRule=Always
@@ -427,3 +428,9 @@ pcg.GlobalDisableRefresh=True
[/Script/Engine.WorldPartitionSettings]
bNewMapsEnableWorldPartition=True
[/Script/NavigationSystem.RecastNavMesh]
RuntimeGeneration=Dynamic
[/Script/GameplayDebugger.GameplayDebuggerConfig]
ActivationKey=+

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +1,9 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=03B8232F_002D11B9_002D83D3_002D1939_002D6F5D0E086489_002Fd_003APrivate_002Fd_003ANavMesh_002Ff_003APImplRecastNavMesh_002Ecpp/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=03B8232F_002D11B9_002D83D3_002D1939_002D6F5D0E086489_002Fd_003APrivate_002Fd_003ANavMesh_002Ff_003ARecastNavMesh_002Ecpp/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=0CD3573C_002DB09F_002DB69D_002DC1DF_002DB6EE0C2BE671_002Fd_003APrivate_002Ff_003AAnimationEditorViewportClient_002Ecpp/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=0CD3573C_002DB09F_002DB69D_002DC1DF_002DB6EE0C2BE671_002Fd_003APrivate_002Ff_003ASkeletonSelectionEditMode_002Ecpp/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWyxudat_002Easm_002Fl_003A_002E_002E_003F_002E_002E_003FUsers_003Fadnan_003FAppData_003FLocal_003FTemp_003FSandboxFiles_003FPufoged_003FWyxudat_002Easm/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWyxudat_002Easm_002Fl_003A_002E_002E_003F_002E_002E_003FUsers_003Fadnan_003FAppData_003FLocal_003FTemp_003FSandboxFiles_003FPufoged_003FWyxudat_002Easm/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=9230b44b_002Dfaf1_002D45dd_002Daa10_002De0de032da2ea/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="MCP" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;Solution /&gt;&#xD;
&lt;/SessionState&gt;</s:String></wpf:ResourceDictionary>

View File

@@ -2,6 +2,8 @@
#include "NpcScheduleAsset.h"
#include "GameFramework/Actor.h"
#include "ProjectEleri/GameObjects/EleriStandPoint.h"
bool FScheduledAction::ContainsTimeOfDay(int32 MinutesOfDay) const
{
@@ -50,7 +52,43 @@ const FScheduledAction* UNpcScheduleAsset::FindActionForTime(int32 MinutesOfDay)
bool FResolvedNpcAction::IsSameAssignment(const FResolvedNpcAction& Other) const
{
return Activity == Other.Activity
&& AnchorGuid == Other.AnchorGuid
&& RoamPathGuid == Other.RoamPathGuid;
&& AnchorGuid == Other.AnchorGuid;
}
bool FResolvedNpcAction::GetAnchorTransform(FTransform& OutTransform) const
{
// The live actor first, because this is the case that has to keep working when a bed is moved
// while an NPC is walking to it, and because a resolved anchor is the only one that carries a
// rotation worth having. Where that actor is furniture with a stand point authored on it, the stand
// point is the answer and not the actor's own origin, which for a bench sits inside the hole the
// bench leaves in the navmesh and is somewhere no NPC can be sent. See EleriStandPoint.
if (const AActor* AnchorActor = Anchor.Get())
{
OutTransform = EleriStandPoint::ResolveAnchorTransform(AnchorActor);
return true;
}
// Streamed out. An invalid Guid means this action never had an anchor to lose track of, which is
// a different thing from one that cannot be seen at the moment: only the latter is a place, and
// AnchorTransform is where it was the last time the schedule subsystem could see it.
if (!bAnchorResolved && AnchorGuid.IsValid())
{
OutTransform = AnchorTransform;
return true;
}
return false;
}
bool FResolvedNpcAction::GetAnchorLocation(FVector& OutLocation) const
{
FTransform ResolvedAnchor;
if (!GetAnchorTransform(ResolvedAnchor))
{
return false;
}
OutLocation = ResolvedAnchor.GetLocation();
return true;
}

View File

@@ -39,15 +39,14 @@ struct FScheduledAction {
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)
* means the action is not tied to any anchor.
*
* Assigned by picking the actor in the details panel rather than by typing, see
* FNpcScheduleActionCustomization. Stored as an id rather than an actor pointer so a re-cook or
* a re-save cannot leave the entry dangling. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (DisplayName = "Anchor"))
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. */
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FGuid RoamPathGuid;
/** Higher wins when two windows overlap. Equal priorities fall back to authoring order. */
UPROPERTY(EditAnywhere, BlueprintReadOnly)
int32 Priority = 0;
@@ -101,13 +100,6 @@ struct FResolvedNpcAction
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)
@@ -116,4 +108,25 @@ struct FResolvedNpcAction
/** 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;
/** Where this action happens, with the rotation the anchor is authored at. False means there is
* nowhere to be rather than a place at the origin: an action with no anchor at all, or one whose
* anchor has streamed out and was never seen while it was loaded.
*
* A live anchor actor wins over the remembered transform, because that actor is where the bed
* actually is right now, and the transform is only ever the last place it was seen before its
* cell went. Both answers are the same thing to a caller, which is just somewhere to stand and
* a way to face, and it is why this can be re-read every tick and stay right without the caller
* knowing anything about streaming.
*
* Somewhere to stand is the whole of it, which is why an anchor with a stand point authored on it
* is answered for by that stand point rather than by the anchor's own origin. For a bench the
* origin is inside the navmesh hole the bench itself leaves behind, and an NPC sent there never
* arrives. See EleriStandPoint::ResolveAnchorTransform, which this and the schedule subsystem's
* remembered transforms both go through, so that the two of them cannot come out differently. */
bool GetAnchorTransform(FTransform& OutTransform) const;
/** GetAnchorTransform reduced to the point itself, for a caller that does not care which way the
* anchor faces. Same rules and the same false. */
bool GetAnchorLocation(FVector& OutLocation) const;
};

View File

@@ -6,6 +6,7 @@
#include "ProjectEleri/System/Subsystem/ScheduleSubsystem.h"
#include "ProjectEleri/Public/TimeOfDayManager.h"
#include "ProjectEleri/Public/EleriGameState.h"
#include "ProjectEleri/NPC/NpcStateMachineComponent.h"
#include "GameplayStateTreeModule/Public/Components/StateTreeComponent.h"
#include "GameFramework/PawnMovementComponent.h"
#include "StructUtils/InstancedStruct.h"
@@ -57,7 +58,6 @@ FNpcScheduleSaveData UNpcScheduleComponent::PrepareSaveData() const
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.
@@ -91,7 +91,6 @@ void UNpcScheduleComponent::ApplySaveData(const FNpcScheduleSaveData& Data)
CurrentAction = FResolvedNpcAction();
CurrentAction.Activity = static_cast<ENpcActivity>(Data.Activity);
CurrentAction.AnchorGuid = Data.AnchorGuid;
CurrentAction.RoamPathGuid = Data.RoamPathGuid;
bHasAction = true;
}
@@ -120,18 +119,18 @@ void UNpcScheduleComponent::TickComponent(float DeltaTime, ELevelTick TickType,
}
}
void UNpcScheduleComponent::RefreshSchedule()
bool UNpcScheduleComponent::RefreshSchedule()
{
if (!NpcTag.IsValid())
{
return;
return false;
}
UScheduleSubsystem* Subsystem = GetScheduleSubsystem();
ATimeOfDayManager* Clock = FindTimeOfDayManager();
if (Subsystem == nullptr || Clock == nullptr)
{
return;
return false;
}
FResolvedNpcAction Resolved;
@@ -139,28 +138,36 @@ void UNpcScheduleComponent::RefreshSchedule()
{
// 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;
return false;
}
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.
// refreshed. Handing it over again would restart whatever the NPC is in the middle of, and it
// would never actually get anywhere.
CurrentAction.Anchor = Resolved.Anchor;
CurrentAction.AnchorTransform = Resolved.AnchorTransform;
CurrentAction.bAnchorResolved = Resolved.bAnchorResolved;
CurrentAction.RoamPath = Resolved.RoamPath;
return;
return false;
}
CurrentAction = Resolved;
bHasAction = true;
if (UNpcStateMachineComponent* Machine = GetStateMachineComponent())
{
// Only one of the two can be driving this NPC, and the one an ANpcBaseCharacter creates for
// itself is the machine. The tree below is what an NPC that has one but no machine still uses,
// which is how a pawn with a StateTree component of its own keeps working untouched.
Machine->OnScheduleActionChanged();
return true;
}
UStateTreeComponent* Tree = GetStateTreeComponent();
if (Tree == nullptr)
{
return;
return true;
}
FInstancedStruct Payload;
@@ -168,12 +175,14 @@ void UNpcScheduleComponent::RefreshSchedule()
Payload.GetMutable<FResolvedNpcAction>() = CurrentAction;
Tree->SendStateTreeEvent(TimeOfDay::ScheduleActionChanged, Payload, FName());
return true;
}
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.
// because applying the unfrozen state is what starts the behaviour of an in range NPC.
if (bFreezeStateApplied && bFrozen == bNewFrozen)
{
return;
@@ -209,6 +218,13 @@ void UNpcScheduleComponent::ApplyFreezeState()
Owner->SetActorEnableCollision(false);
Owner->SetActorTickEnabled(false);
if (UNpcStateMachineComponent* Machine = GetStateMachineComponent())
{
// Told before the tree is paused, because for this NPC the machine is the behaviour: what
// stops it is being told, and the pause below is for whichever NPC is still a tree.
Machine->OnNpcFrozen();
}
if (UStateTreeComponent* Tree = GetStateTreeComponent())
{
// Paused rather than stopped, so the task it was in the middle of is still there to be
@@ -219,7 +235,7 @@ void UNpcScheduleComponent::ApplyFreezeState()
return;
}
// Unfrozen. The NPC is put back exactly as it was and then handed back to its StateTree.
// Unfrozen. The NPC is put back exactly as it was and then handed back to whatever drives it.
if (Movement != nullptr)
{
Movement->SetComponentTickEnabled(true);
@@ -229,6 +245,18 @@ void UNpcScheduleComponent::ApplyFreezeState()
Owner->SetActorEnableCollision(true);
Owner->SetActorTickEnabled(true);
// The clock is read here rather than being left to the next tick, because the answer to "did this
// NPC's day move on while it was away" is what decides how it comes back: onto the anchor
// outright when it did not, and walking there when it did. The return value is that answer.
const bool bAssignmentChanged = RefreshSchedule();
if (UNpcStateMachineComponent* Machine = GetStateMachineComponent())
{
// Last chance to ask: from here on the player is close enough to watch this NPC move.
Machine->OnNpcUnfrozen(bAssignmentChanged);
return;
}
UStateTreeComponent* Tree = GetStateTreeComponent();
if (Tree == nullptr)
{
@@ -306,6 +334,25 @@ UStateTreeComponent* UNpcScheduleComponent::GetStateTreeComponent()
return Owner != nullptr ? Owner->FindComponentByClass<UStateTreeComponent>() : nullptr;
}
UNpcStateMachineComponent* UNpcScheduleComponent::GetStateMachineComponent()
{
if (CachedStateMachine.IsValid())
{
return CachedStateMachine.Get();
}
const AActor* Owner = GetOwner();
if (Owner == nullptr)
{
return nullptr;
}
// Searched once and kept, and searched again only after the component it found has gone away,
// which is what lets a machine added at runtime be picked up on the next refresh.
CachedStateMachine = Owner->FindComponentByClass<UNpcStateMachineComponent>();
return CachedStateMachine.Get();
}
UScheduleSubsystem* UNpcScheduleComponent::GetScheduleSubsystem()
{
const UWorld* World = GetWorld();

View File

@@ -11,6 +11,7 @@
class ATimeOfDayManager;
class UStateTreeComponent;
class UNpcStateMachineComponent;
class UScheduleSubsystem;
/**
@@ -22,6 +23,11 @@ class UScheduleSubsystem;
*
* 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.
*
* It is not itself the NPC's behaviour. What an assignment means is the state machine component's
* business: this works out what the NPC should be doing and hands it over, and freezing is part of
* that handover, because whether the NPC was watched while it was away is what decides whether it may
* be put back in place outright or has to be seen walking there.
*/
UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent), BlueprintType, Blueprintable)
class PROJECTELERI_API UNpcScheduleComponent : public UActorComponent
@@ -41,7 +47,7 @@ public:
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
* behaviour, 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;
@@ -57,10 +63,21 @@ public:
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. */
/** True once the clock has given this NPC an assignment. Until it has there is nothing to walk to
* and nothing to do. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Schedule")
bool HasAction() const { return bHasAction; }
/** What the clock last decided this NPC should be doing. Meaningless before HasAction. Not a
* blueprint function because CurrentAction above is already readable from a blueprint. */
const FResolvedNpcAction& GetCurrentAction() const { return CurrentAction; }
/** Re-reads the clock. Returns true when that produced an assignment different from the one this
* NPC was already on, which is the question the state machine asks on unfreeze: an NPC whose day
* has moved on without it while it was out of sight is one that has to be seen walking, and one
* whose assignment is unchanged can simply be put where it should already have been. */
UFUNCTION(BlueprintCallable, Category = "Schedule")
void RefreshSchedule();
bool RefreshSchedule();
UFUNCTION(BlueprintCallable, Category = "Schedule")
void SetFrozen(bool bNewFrozen);
@@ -86,17 +103,22 @@ protected:
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. */
* application always happens, which is what starts the behaviour of an in range NPC. */
void ApplyFreezeState();
UStateTreeComponent* GetStateTreeComponent();
/** The behaviour driving this NPC, null when it has none. Found once and kept, because this is
* asked for on every refresh and every freeze. */
UNpcStateMachineComponent* GetStateMachineComponent();
UScheduleSubsystem* GetScheduleSubsystem();
private:
TWeakObjectPtr<ATimeOfDayManager> CachedTimeOfDayManager;
TWeakObjectPtr<AActor> CachedViewTarget;
TWeakObjectPtr<UNpcStateMachineComponent> CachedStateMachine;
/** False until the freeze state has been applied once */
bool bFreezeStateApplied = false;

View File

@@ -0,0 +1,157 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "NpcWalkToTask.h"
#include "GameFramework/Pawn.h"
#include "StateTreeExecutionContext.h"
#include "ProjectEleri/Ai/NpcScheduleComponent.h"
#include "ProjectEleri/NPC/ExtendedPawnMovement.h"
namespace NpcWalkToTaskDetail
{
/** The pawn this tree is running on. A tree that lives on a StateTree component, which is how an
* NPC owns one, has that component as its context owner, so the pawn is the actor holding it. A
* tree owned by an actor directly works as well, because this task is about the pawn and not
* about who happens to own the tree. */
APawn* ResolvePawn(const FStateTreeExecutionContext& Context)
{
UObject* Owner = Context.GetOwner();
if (APawn* OwnerPawn = Cast<APawn>(Owner))
{
return OwnerPawn;
}
if (const UActorComponent* OwnerComponent = Cast<UActorComponent>(Owner))
{
return Cast<APawn>(OwnerComponent->GetOwner());
}
return nullptr;
}
/** The movement this task drives, looked up rather than required: a pawn that has none simply
* cannot be walked, and reporting that is this task's business rather than an error here. */
UExtendedPawnMovement* FindMovement(const APawn* Pawn)
{
return Pawn != nullptr ? Pawn->FindComponentByClass<UExtendedPawnMovement>() : nullptr;
}
/** Where the action this NPC is on right now happens, in world space. False when there is no such
* place, which is nothing to walk to rather than a walk to the origin of the world.
*
* The answer comes from the resolved action itself rather than being worked out here, so that
* this task and the state machine that replaced it for scheduled NPCs cannot end up disagreeing
* about where a streamed out anchor used to be. */
bool GetScheduleAnchorLocation(const APawn* Pawn, FVector& OutLocation)
{
const UNpcScheduleComponent* Schedule = Pawn != nullptr ? Pawn->FindComponentByClass<UNpcScheduleComponent>() : nullptr;
return Schedule != nullptr && Schedule->HasAction()
&& Schedule->GetCurrentAction().GetAnchorLocation(OutLocation);
}
/** Where this task wants the pawn to walk to right now. */
bool ResolveDestination(const FStateTreeExecutionContext& Context, const FStateTreeNpcWalkToTaskInstanceData& InstanceData,
const ENpcWalkTargetSource TargetSource, FVector& OutLocation)
{
switch (TargetSource)
{
case ENpcWalkTargetSource::ScheduleActionAnchor:
return GetScheduleAnchorLocation(ResolvePawn(Context), OutLocation);
case ENpcWalkTargetSource::InputLocation:
default:
OutLocation = InstanceData.TargetLocation;
return true;
}
}
}
EStateTreeRunStatus FStateTreeNpcWalkToTask::EnterState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition) const
{
FInstanceDataType& InstanceData = Context.GetInstanceData(*this);
InstanceData.ElapsedTime = 0.f;
UExtendedPawnMovement* Movement = NpcWalkToTaskDetail::FindMovement(NpcWalkToTaskDetail::ResolvePawn(Context));
if (Movement == nullptr)
{
// Nothing here can walk. Saying so is more use than a task that stands there believing it is
// getting on with the job, because the state that asked for this NPC to be moved wants the
// transition it wrote for a walk that cannot happen.
return EStateTreeRunStatus::Failed;
}
FVector Destination = FVector::ZeroVector;
if (!NpcWalkToTaskDetail::ResolveDestination(Context, InstanceData, TargetSource, Destination))
{
// Nowhere to go: an action with no anchor, or one whose anchor could not be resolved. That is
// a gap in the schedule rather than a walk, so the tree hears about it instead.
return EStateTreeRunStatus::Failed;
}
Movement->StartMoveToLocation(Destination, InstanceData.Speed, InstanceData.AcceptanceRadius);
return EStateTreeRunStatus::Running;
}
EStateTreeRunStatus FStateTreeNpcWalkToTask::Tick(FStateTreeExecutionContext& Context, const float DeltaTime) const
{
FInstanceDataType& InstanceData = Context.GetInstanceData(*this);
UExtendedPawnMovement* Movement = NpcWalkToTaskDetail::FindMovement(NpcWalkToTaskDetail::ResolvePawn(Context));
if (Movement == nullptr)
{
return EStateTreeRunStatus::Failed;
}
// The destination is re-read and the walk re-armed every tick rather than only on entry, because
// what is being walked to can change underneath the state: a schedule hands out a new anchor when
// the assignment changes, an anchor in a streamed out cell comes back as the transform it was
// last seen at, and being frozen and thawed leaves this task exactly where it was with a
// destination that may well be stale by then. A destination read once on entry would have an NPC
// walking to where a bed used to be.
FVector Destination = FVector::ZeroVector;
if (!NpcWalkToTaskDetail::ResolveDestination(Context, InstanceData, TargetSource, Destination))
{
Movement->StopMoving();
return EStateTreeRunStatus::Failed;
}
Movement->StartMoveToLocation(Destination, InstanceData.Speed, InstanceData.AcceptanceRadius);
if (InstanceData.MaxWalkTime > 0.f)
{
InstanceData.ElapsedTime += DeltaTime;
if (InstanceData.ElapsedTime >= InstanceData.MaxWalkTime)
{
// Out of time, so the walk is over with the pawn wherever it managed to get to. This ends
// the task rather than failing it: being late is a state a tree usually wants to move on
// from, and either way the pawn has been left standing still.
Movement->StopMoving();
return EStateTreeRunStatus::Succeeded;
}
}
if (Movement->HasReachedMoveTarget())
{
// Stopped here as well as reported. The movement component would otherwise carry on walking
// in whichever direction the last tick left it in, and a task that ends without saying so
// leaves its pawn in motion.
Movement->StopMoving();
return EStateTreeRunStatus::Succeeded;
}
return EStateTreeRunStatus::Running;
}
void FStateTreeNpcWalkToTask::ExitState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition) const
{
// Whatever the reason for leaving - the state is done, the tree is being torn down, something
// else is taking the pawn over - a task that moves a pawn has to hand it back standing still.
if (UExtendedPawnMovement* Movement = NpcWalkToTaskDetail::FindMovement(NpcWalkToTaskDetail::ResolvePawn(Context)))
{
Movement->StopMoving();
}
}

View File

@@ -0,0 +1,85 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "StateTreeTaskBase.h"
#include "NpcWalkToTask.generated.h"
/** Where a walk task takes its destination from. */
UENUM(BlueprintType)
enum class ENpcWalkTargetSource : uint8
{
/** The point pinned on the task in the StateTree asset, or bound to it from outside. */
InputLocation UMETA(DisplayName = "Input Location"),
/** Whatever the NPC's schedule says it is on right now. Lets one state walk to the anchor of the
* current action without a binding per state to keep in step with the schedule. */
ScheduleActionAnchor UMETA(DisplayName = "Schedule Action Anchor")
};
USTRUCT()
struct FStateTreeNpcWalkToTaskInstanceData
{
GENERATED_BODY()
/** Where to walk to, when Target Source is Input Location. */
UPROPERTY(EditAnywhere, Category = "Parameter")
FVector TargetLocation = FVector::ZeroVector;
/** How fast to walk, in cm per second. Zero means as fast as the pawn can go, and a value that is
* given is used exactly as given rather than being held down to the pawn's MaxSpeed. */
UPROPERTY(EditAnywhere, Category = "Parameter", meta = (ClampMin = "0.0", UIMin = "0.0"))
float Speed = 0.f;
/** How close to the destination counts as arrived. Negative means the movement component's own
* default, which is the radius to use unless a state has a reason to want another one. */
UPROPERTY(EditAnywhere, Category = "Parameter")
float AcceptanceRadius = -1.f;
/** How long this task may spend walking, in seconds. Zero means no limit at all, so the walk ends
* when the pawn arrives and nowhere else, and a state that has to give up on a walk it cannot
* finish says so with a transition of its own. */
UPROPERTY(EditAnywhere, Category = "Parameter", meta = (ClampMin = "0.0", UIMin = "0.0"))
float MaxWalkTime = 0.f;
/** Time spent on this walk so far, against MaxWalkTime. */
UPROPERTY(VisibleAnywhere, Category = "Output")
float ElapsedTime = 0.f;
};
/**
* Walks a pawn that has no controller to a point, and leaves it stopped there.
*
* This is the native counterpart of ST_Task_WalkTowardsLocation, and it exists because that task
* asks an AIController to walk a pawn along a navmesh path. A scheduled NPC has neither: no
* controller to ask and no path to follow. It has a UExtendedPawnMovement instead, which walks
* itself when it is handed a destination, so this task is the whole of "go over there" for those
* pawns and does it with a straight line and a bit of making way for whoever is on it.
*
* It is a FStateTreeTaskCommonBase, which is the only kind of task a UStateTreeComponentSchema will
* accept, and that is also why it takes its pawn from the execution context rather than from a
* binding: the pawn being moved is always the one the tree is running on.
*
* The task succeeds when the pawn arrives, and fails when there is no pawn to walk (no
* UExtendedPawnMovement on it) or nowhere to walk to. The pawn is left standing still in every case,
* including when a transition pulls the state out from under a walk that is still going.
*/
USTRUCT(meta = (DisplayName = "Npc Walk To"))
struct PROJECTELERI_API FStateTreeNpcWalkToTask : public FStateTreeTaskCommonBase
{
GENERATED_BODY()
using FInstanceDataType = FStateTreeNpcWalkToTaskInstanceData;
virtual const UStruct* GetInstanceDataType() const override { return FInstanceDataType::StaticStruct(); }
virtual EStateTreeRunStatus EnterState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition) const override;
virtual EStateTreeRunStatus Tick(FStateTreeExecutionContext& Context, const float DeltaTime) const override;
virtual void ExitState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition) const override;
/** Where this task's destination comes from. An editing decision rather than a value that changes
* while the tree runs, so it sits on the node instead of in its instance data. */
UPROPERTY(EditAnywhere, Category = "Parameter")
ENpcWalkTargetSource TargetSource = ENpcWalkTargetSource::InputLocation;
};

View File

@@ -15,7 +15,7 @@ class PROJECTELERI_API UEleriFlipbookComponent : public UPaperFlipbookComponent
public:
virtual void BeginPlay() override;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TSoftObjectPtr<UMaterialInterface> DecalMaterial = nullptr;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)

View File

@@ -57,7 +57,10 @@ void UDialogueComponent::StartDialogueWithNpc()
UDialogueSubsystem* DialogueSubsystem = UDialogueSubsystem::Get(this);
ensure(DialogueSubsystem);
if (!DialogueSubsystem->StartDialogue()) return;
// The NPC goes in with it, so that whoever is being talked to can be told apart from the rest of
// the town. This component lives on the actor the player talks to, which makes GetOwner the
// answer, and it is the single funnel every dialogue goes through.
if (!DialogueSubsystem->StartDialogue(GetOwner())) return;
MappedDialogueOptions.Empty();
FilterSpeechData();
@@ -119,7 +122,7 @@ void UDialogueComponent::TriggerDialogue(USpeechDataAsset* DialogueAssetPath, co
//TriggerDialogueSelect(PlayerSpeakOptions, false);
}
DialogueSubsystem->StartDialogue();
DialogueSubsystem->StartDialogue(GetOwner());
}
TArray<USpeechDataAsset*> UDialogueComponent::GetActiveSpeechData() { return ActiveSpeechData; }

View File

@@ -56,17 +56,33 @@ TArray<TObjectPtr<USpeechDataAsset>> UDialogueSubsystem::GetDialogueForActor(con
return ActorDialogue;
}
bool UDialogueSubsystem::StartDialogue()
bool UDialogueSubsystem::StartDialogue(AActor* Speaker)
{
if(bDialogueStarted) return false;
bDialogueStarted = true;
SetActiveSpeaker(Speaker);
return true;
}
void UDialogueSubsystem::EndDialogue()
{
bDialogueStarted = false;
// Cleared even when nothing named a speaker, because the NPC that was talking to the player is
// what stops it walking off mid conversation and it has to be told the conversation is over.
SetActiveSpeaker(nullptr);
}
void UDialogueSubsystem::SetActiveSpeaker(AActor* Speaker)
{
if(ActiveSpeaker.Get() == Speaker)
{
return;
}
ActiveSpeaker = Speaker;
OnDialogueSpeakerChanged.Broadcast(Speaker);
}
void UDialogueSubsystem::DialogueAssetsLoaded()

View File

@@ -9,6 +9,7 @@
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnDialogueAssetsLoaded);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnDialogueEnded);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnDialogueGameplayEvent, FGameplayTag, GameplayTag);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnDialogueSpeakerChanged, AActor*, Speaker);
class USpeechDataAsset;
/**
@@ -27,6 +28,12 @@ public:
FOnDialogueEnded OnDialogueEnded;
UPROPERTY(BlueprintAssignable, BlueprintCallable, Category = "Eleri|Dialogue")
FOnDialogueGameplayEvent OnDialogueGameplayEvent;
/** Fired whenever the player's conversation partner changes, including when one ends and there is
* nobody left. A dialogue that was started without a speaker broadcasts a null here too, so a
* listener can tell "talking to nobody in particular" from "not talking at all". */
UPROPERTY(BlueprintAssignable, BlueprintCallable, Category = "Eleri|Dialogue")
FOnDialogueSpeakerChanged OnDialogueSpeakerChanged;
static UDialogueSubsystem* Get(const UObject* WorldContext);
@@ -36,8 +43,23 @@ public:
bool IsDialogueStarted() const {return bDialogueStarted; }
TArray<TObjectPtr<USpeechDataAsset>> GetDialogueForActor(const AActor* Actor);
/** Whoever the player is talking to, or null when no dialogue is running or when it was started
* by something that did not name an actor. */
UFUNCTION(BlueprintPure, Category = "Eleri|Dialogue")
AActor* GetActiveSpeaker() const { return ActiveSpeaker.Get(); }
/** True while the player is talking to Actor specifically. This is the question an NPC asks so
* that a conversation with one of them does not stop the rest of the town from going about its
* day, which is exactly what the dialogue-wide IsDialogueStarted cannot tell you. */
UFUNCTION(BlueprintPure, Category = "Eleri|Dialogue")
bool IsSpeakingTo(const AActor* Actor) const { return Actor != nullptr && ActiveSpeaker.Get() == Actor; }
/** Starts a dialogue and records who it is with. Speaker is optional so that a caller which only
* cares that a dialogue is running, and a dialogue started from somewhere that is not an actor
* at all, keep working: the speaker tracking is an addition to this subsystem and not a
* requirement of it. */
UFUNCTION(BlueprintCallable)
bool StartDialogue();
bool StartDialogue(AActor* Speaker = nullptr);
UFUNCTION(BlueprintCallable)
void EndDialogue();
@@ -55,5 +77,14 @@ protected:
UPROPERTY()
TArray<TObjectPtr<USpeechDataAsset>> AllSpeechData;
/** The actor this dialogue is being held with, weak because it is an actor in the level and this
* subsystem outlives every level. */
UPROPERTY()
TWeakObjectPtr<AActor> ActiveSpeaker;
void DialogueAssetsLoaded();
/** The one place that writes ActiveSpeaker, so that the change delegate cannot be fired twice for
* one change or missed entirely when the speaker goes back to null. */
void SetActiveSpeaker(AActor* Speaker);
};

View File

@@ -9,7 +9,10 @@ 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();
// PersistentId is deliberately not set here. A class default object is built by this constructor
// too, and an id on the archetype is an id that an instance can match without differing from it,
// which is what keeps it from ever being written to disk. EleriPersistentId::Ensure fills the id in
// on every real instance instead, see the helper for the whole of the reasoning.
USceneComponent* RootComp = CreateDefaultSubobject<USceneComponent>("Root");
SetRootComponent(RootComp);
@@ -18,4 +21,97 @@ ABasicSplineActor::ABasicSplineActor()
Spline->SetupAttachment(RootComp);
}
void ABasicSplineActor::PostInitProperties()
{
Super::PostInitProperties();
EleriPersistentId::Ensure(PersistentId, *this);
}
void ABasicSplineActor::PostLoad()
{
Super::PostLoad();
// Nothing is marked dirty here on purpose: the editor suppresses that while a package is loading,
// and dirtying a level merely for being opened is against policy anyway. What this heals is the
// running session, so that an actor whose level data predates the property still has the one id
// everything else in this session refers to. Making it permanent takes a save of the level, which
// is what the report button on the actor is there to tell the author about.
EleriPersistentId::Ensure(PersistentId, *this);
}
void ABasicSplineActor::PostActorCreated()
{
Super::PostActorCreated();
EleriPersistentId::Ensure(PersistentId, *this);
}
void ABasicSplineActor::RegeneratePersistentId()
{
const FGuid PreviousId = PersistentId;
// Modified before the write, so the regeneration is an edit of this actor that undo can step back
// out of, like any other change made from the details panel.
Modify();
EleriPersistentId::Regenerate(PersistentId, *this);
MarkPackageDirty();
UE_LOG(LogTemp, Display, TEXT("%s: PersistentId %s -> %s"), *GetActorNameOrLabel(),
PreviousId.IsValid() ? *PreviousId.ToString(EGuidFormats::DigitsWithHyphens) : TEXT("(none)"),
*PersistentId.ToString(EGuidFormats::DigitsWithHyphens));
// Whatever already refers to this actor still holds the old id, so how many actors share it is the
// one thing that turns "the id changed" into something the author can act on.
const int32 RemainingClaimants = EleriPersistentId::CountOtherClaimants(*this, PreviousId);
if (RemainingClaimants > 0)
{
UE_LOG(LogTemp, Warning, TEXT("%s: %d other actor(s) in this level still claim the id it had. Anything that stored that id now resolves to one of them and has to be reassigned."),
*GetActorNameOrLabel(), RemainingClaimants);
}
}
void ABasicSplineActor::ReportPersistentId()
{
// Read first, because the message for each state needs it and an invalid id is the state that has
// to be reported rather than counted.
const FString IdText = PersistentId.IsValid()
? PersistentId.ToString(EGuidFormats::DigitsWithHyphens)
: TEXT("(none)");
const int32 Claimants = EleriPersistentId::CountOtherClaimants(*this, PersistentId);
if (!PersistentId.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("%s: PersistentId %s. Nothing can point at this actor and a save files it under the empty id. Reopen the level or press Regenerate Persistent Id."),
*GetActorNameOrLabel(), *IdText);
return;
}
// -1 is "no world to look in", which is not a clean bill of health and must not be reported as one.
if (Claimants < 0)
{
UE_LOG(LogTemp, Warning, TEXT("%s: PersistentId %s. This actor is not in a world, so nothing could be checked against it."),
*GetActorNameOrLabel(), *IdText);
return;
}
if (Claimants == 0)
{
UE_LOG(LogTemp, Display, TEXT("%s: PersistentId %s, claimed by no other actor in this level."),
*GetActorNameOrLabel(), *IdText);
return;
}
UE_LOG(LogTemp, Warning, TEXT("%s: PersistentId %s is ALSO claimed by %d other actor(s) in this level. A schedule or a save that stores it resolves to whichever of them is reached first, so give all but one of them an id of their own."),
*GetActorNameOrLabel(), *IdText, Claimants);
}
#if WITH_EDITOR
EDataValidationResult ABasicSplineActor::IsDataValid(FDataValidationContext& Context) const
{
return EleriPersistentId::ValidateId(*this, PersistentId, Context, Super::IsDataValid(Context));
}
#endif

View File

@@ -5,6 +5,7 @@
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Components/SplineComponent.h"
#include "Misc/DataValidation.h"
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
#include "BasicSplineActor.generated.h"
@@ -18,17 +19,53 @@ public:
// Sets default values for this actor's properties
ABasicSplineActor();
/** Gives a freshly constructed actor an id. Every construction path comes through here, including
* the class default objects, which is why the helper refuses to hand a template one. */
virtual void PostInitProperties() override;
/** The path a placed actor's id actually arrives through, and the one that repairs level data
* written before the id was recorded for this actor: by the time this runs the property has been
* read back, so an actor whose data predates it is still sitting on an invalid id and is given
* one here. Once its level data holds the property this is a call that finds an id and keeps it. */
virtual void PostLoad() override;
/** The spawn path. A runtime spawned actor is never loaded and never placed, so this is the only
* moment it can be given an id. PIE duplicates do not come through here, which is deliberate: a
* duplicate keeps the id of the actor it was copied from, so a PIE object and the editor actor it
* stands for agree on the one id a save or a schedule stores. */
virtual void PostActorCreated() override;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
USplineComponent* Spline;
//----- IActorPersistentIdInterface -----
virtual FGuid GetPersistentId() const override { return PersistentId; }
virtual void SetPersistentId(const FGuid& InPersistentId) override { PersistentId = InPersistentId; }
/** Hands this actor a new id and logs the one it had, for the case the id has to be broken on
* purpose: an actor pasted into the level in the editor keeps its source's id, and two actors
* claiming one id is a schedule that resolves to whichever of them a search reaches first. */
UFUNCTION(BlueprintCallable, CallInEditor, Category = "ID")
void RegeneratePersistentId();
/** Logs this actor's id and says whether anything else in the level claims it, so a duplicate can
* be found without starting a game. A log rather than a dialog, because several actors can be
* selected at once and one dialog per actor would have to be dismissed one at a time. */
UFUNCTION(BlueprintCallable, CallInEditor, Category = "ID")
void ReportPersistentId();
#if WITH_EDITOR
/** Flags a missing or duplicated id from the level's validation pass, which is what catches a
* duplicate before it becomes a schedule that walks an NPC to the wrong object. */
virtual EDataValidationResult IsDataValid(FDataValidationContext& Context) const override;
#endif
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. */
/** Stable identity for anything that has to point at this actor. It survives save/load and a
* re-cook, unlike a raw actor pointer, and it is serialized with the placed instance instead of
* being rolled in the constructor, so it is the same id every time the level is opened. See
* EleriPersistentId for why the constructor is the one place it must not be set. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "ID")
FGuid PersistentId;
};

View File

@@ -9,13 +9,77 @@ AEleriBaseActor::AEleriBaseActor()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
PersistentId = FGuid::NewGuid();
// PersistentId is deliberately not set here. A class default object is built by this constructor
// too, and an id on the archetype is an id that an instance can match without differing from it,
// which is what keeps it from ever being written to disk. EleriPersistentId::Ensure fills the id in
// on every real instance instead, see the helper for the whole of the reasoning.
RootComponent = CreateDefaultSubobject<USceneComponent>(TEXT("RootComponent"));
SetRootComponent(RootComponent);
WidgetComponent = CreateDefaultSubobject<UInteractionWidgetComponent>(TEXT("WidgetComponent"));
WidgetComponent->SetupAttachment(RootComponent);
// Created on the actor's own origin and pointing the way the actor points, because that is the one
// placement an author cannot mean: a stand point is a place beside the furniture, so a transform
// still sitting exactly where it was created is read as nobody having said anything. The component
// brings its own manners with it, an arrow being no-collision, hidden in game and editor only, and
// deliberately unpickable in the viewport so that it can never be dragged by accident.
StandPoint = CreateDefaultSubobject<UArrowComponent>(TEXT("StandPoint"));
StandPoint->SetupAttachment(RootComponent);
}
bool AEleriBaseActor::GetStandPointTransform(FTransform& OutTransform) const
{
if (StandPoint == nullptr)
{
return false;
}
// Read relative to the actor and not in world space, so that an actor turned to face the street
// does not read as an authored stand point: what the author writes is where to go, and the actor's
// own placement is carried by the transform handed back below rather than judged here.
const bool bPlaced = !StandPoint->GetRelativeLocation().IsZero()
|| !StandPoint->GetRelativeRotation().IsZero();
if (!bPlaced)
{
return false;
}
// The world transform, so that the stand point follows the actor it is authored on: whoever moves
// the whole bench, or the room the bench stands in, has moved the place to stand with it, and a
// stand point that had to be put back by hand after every move would be worse than no stand point.
OutTransform = StandPoint->GetComponentTransform();
return true;
}
void AEleriBaseActor::PostInitProperties()
{
Super::PostInitProperties();
EleriPersistentId::Ensure(PersistentId, *this);
}
void AEleriBaseActor::PostLoad()
{
Super::PostLoad();
// Nothing is marked dirty here on purpose: the editor suppresses that while a package is loading,
// and dirtying a level merely for being opened is against policy anyway. What this heals is the
// running session, so that an actor whose level data predates the property still has the one id
// everything else in this session refers to, instead of the empty id that would file it together
// with every other unassigned actor in the save. Making it permanent takes a save of the level,
// which is what the report button on the actor is there to tell the author about.
EleriPersistentId::Ensure(PersistentId, *this);
}
void AEleriBaseActor::PostActorCreated()
{
Super::PostActorCreated();
EleriPersistentId::Ensure(PersistentId, *this);
}
// Called when the game starts or when spawned
@@ -92,4 +156,72 @@ void AEleriBaseActor::ToggleInteractableUI_Implementation(bool bActive)
void AEleriBaseActor::OnDataLoaded_Implementation() {}
void AEleriBaseActor::RegeneratePersistentId()
{
const FGuid PreviousId = PersistentId;
// Modified before the write, so the regeneration is an edit of this actor that undo can step back
// out of, like any other change made from the details panel.
Modify();
EleriPersistentId::Regenerate(PersistentId, *this);
MarkPackageDirty();
UE_LOG(LogTemp, Display, TEXT("%s: PersistentId %s -> %s"), *GetActorNameOrLabel(),
PreviousId.IsValid() ? *PreviousId.ToString(EGuidFormats::DigitsWithHyphens) : TEXT("(none)"),
*PersistentId.ToString(EGuidFormats::DigitsWithHyphens));
// Whatever already refers to this actor still holds the old id - a schedule, and above all this
// actor's own cached save data - so how many actors share it is the one thing that turns "the id
// changed" into something the author can act on.
const int32 RemainingClaimants = EleriPersistentId::CountOtherClaimants(*this, PreviousId);
if (RemainingClaimants > 0)
{
UE_LOG(LogTemp, Warning, TEXT("%s: %d other actor(s) in this level still claim the id it had. Anything that stored that id now resolves to one of them and has to be reassigned."),
*GetActorNameOrLabel(), RemainingClaimants);
}
}
void AEleriBaseActor::ReportPersistentId()
{
// Read once, because every branch below reports it and an invalid id is a state to be reported
// rather than something to count.
const FString IdText = PersistentId.IsValid()
? PersistentId.ToString(EGuidFormats::DigitsWithHyphens)
: TEXT("(none)");
const int32 Claimants = EleriPersistentId::CountOtherClaimants(*this, PersistentId);
if (!PersistentId.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("%s: PersistentId %s. Nothing can point at this actor, and the save files it under the empty id together with every other unassigned actor. Reopen the level or press Regenerate Persistent Id."),
*GetActorNameOrLabel(), *IdText);
return;
}
// -1 is "no world to look in", which is not a clean bill of health and must not be reported as one.
if (Claimants < 0)
{
UE_LOG(LogTemp, Warning, TEXT("%s: PersistentId %s. This actor is not in a world, so nothing could be checked against it."),
*GetActorNameOrLabel(), *IdText);
return;
}
if (Claimants == 0)
{
UE_LOG(LogTemp, Display, TEXT("%s: PersistentId %s, claimed by no other actor in this level."),
*GetActorNameOrLabel(), *IdText);
return;
}
UE_LOG(LogTemp, Warning, TEXT("%s: PersistentId %s is ALSO claimed by %d other actor(s) in this level. A schedule or a save that stores it resolves to whichever of them is reached first, so give all but one of them an id of their own."),
*GetActorNameOrLabel(), *IdText, Claimants);
}
#if WITH_EDITOR
EDataValidationResult AEleriBaseActor::IsDataValid(FDataValidationContext& Context) const
{
return EleriPersistentId::ValidateId(*this, PersistentId, Context, Super::IsDataValid(Context));
}
#endif

View File

@@ -4,11 +4,13 @@
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Misc/DataValidation.h"
#include "ProjectEleri/Public/Interface/SaveableObjectInterface.h"
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
#include "GameplayTagContainer.h"
#include "Components/InteractionWidgetComponent.h"
#include "Interface/InteractableActorInterface.h"
#include "Components/ArrowComponent.h"
#include "EleriBaseActor.generated.h"
@@ -27,14 +29,64 @@ public:
// Called every frame
virtual void Tick(float DeltaTime) override;
/** Gives a freshly constructed actor an id. Every construction path comes through here, including
* the class default objects, which is why the helper refuses to hand a template one. */
virtual void PostInitProperties() override;
/** The path a placed actor's id actually arrives through, and the one that repairs level data
* written before the id was recorded for this actor: by the time this runs the property has been
* read back, so an actor whose data predates it is still sitting on an invalid id and is given
* one here. Once its level data holds the property this is a call that finds an id and keeps it. */
virtual void PostLoad() override;
/** The spawn path. A runtime spawned actor is never loaded and never placed, so this is the only
* moment it can be given an id. PIE duplicates do not come through here, which is deliberate: a
* duplicate keeps the id of the actor it was copied from, so a PIE object and the editor actor it
* stands for agree on the one id a save or a schedule stores. */
virtual void PostActorCreated() override;
//----- ISaveableObjectInterface -----
virtual FActorSaveData RequestSave() override;
virtual void RequestLoad(FActorSaveData &Data) override;
//----- IActorPersistentIdInterface -----
virtual FGuid GetPersistentId() const override { return PersistentId; }
virtual void SetPersistentId(const FGuid& InPersistentId) override { PersistentId = InPersistentId; }
/** Hands this actor a new id and logs the one it had, for the case the id has to be broken on
* purpose: an actor pasted into the level in the editor keeps its source's id, and two actors
* claiming one id is a schedule that resolves to whichever of them a search reaches first. */
UFUNCTION(BlueprintCallable, CallInEditor, Category = "ID")
void RegeneratePersistentId();
/** Logs this actor's id and says whether anything else in the level claims it, so a duplicate can
* be found without starting a game. A log rather than a dialog, because several actors can be
* selected at once and one dialog per actor would have to be dismissed one at a time. */
UFUNCTION(BlueprintCallable, CallInEditor, Category = "ID")
void ReportPersistentId();
#if WITH_EDITOR
/** Flags a missing or duplicated id from the level's validation pass, which is what catches a
* duplicate before it becomes a schedule that walks an NPC to the wrong object. */
virtual EDataValidationResult IsDataValid(FDataValidationContext& Context) const override;
#endif
virtual void ToggleInteractableUI_Implementation(bool bActive) override;
//----- Anchor -----
/** Where an NPC should stand to use this actor, and false when the author has not said: an actor
* whose stand point arrow is still sitting on its own origin with the actor's own rotation is an
* actor nobody has put a stand point on. An arrow that has only been turned counts as placed even
* though it is in the same spot, because an NPC that stays where it is and faces the other way is
* a real thing to ask for.
*
* That false is not a fault, and it is the answer for every anchor that is not furniture: a spot
* marked by a marker actor or a merchant's place behind a stall is somewhere to stand already. The
* one caller in the schedule reads it through EleriStandPoint::ResolveAnchorTransform, which falls
* back to the actor's own transform for exactly this answer. */
bool GetStandPointTransform(FTransform& OutTransform) const;
protected:
virtual void OnDataLoaded_Implementation() override;
@@ -46,12 +98,27 @@ protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Saveable Tag Data", meta = (GameplayTagFilter = "SaveGameTags"))
TMap<FGameplayTag, int32> SaveableTagData;
/** Stable identity for anything that has to point at this actor, and the key its save data is filed
* under. It is serialized with the placed instance instead of being rolled in the constructor, so
* it is the same id every time the level is opened. See EleriPersistentId for why the constructor
* is the one place it must not be set. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "ID")
FGuid PersistentId;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
UInteractionWidgetComponent* WidgetComponent;
/** Where an NPC stands when a schedule sends it here, drawn as an arrow at floor level. Authored on
* the furniture because the furniture's own origin is a place with no floor: a bench or a bed
* carves a hole in the navmesh the size of its own footprint, so its origin sits over that hole and
* a walk aimed at it has no polygon to arrive on, see EleriStandPoint for the whole of it.
*
* Placed by dragging it in the Components panel, arrows being deliberately unpickable in the
* viewport, and left on the actor's own origin by everything with no use for a stand point, which
* is every anchor that is not furniture. See GetStandPointTransform for what that leaves in place. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Anchor")
TObjectPtr<UArrowComponent> StandPoint;
UFUNCTION(BlueprintCallable, Category = "Saveable Tag Data", meta = (GameplayTagFilter = "SaveGameTags"))
void SetSaveableTagData(FGameplayTag Tag, int32 NewValue);
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Saveable Tag Data", meta = (GameplayTagFilter = "SaveGameTags"))

View File

@@ -0,0 +1,28 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "EleriStandPoint.h"
#include "ProjectEleri/GameObjects/EleriBaseActor.h"
FTransform EleriStandPoint::ResolveAnchorTransform(const AActor* Anchor)
{
if (Anchor == nullptr)
{
return FTransform::Identity;
}
// A cast rather than a check against an interface, because a stand point is one actor's own
// property and not part of a contract anything can be asked for: an anchor that is not a base actor
// at all, a marker actor or a merchant's place behind a stall, is answered for by its transform
// below rather than reported as having no place to stand.
if (const AEleriBaseActor* BaseActor = Cast<AEleriBaseActor>(Anchor))
{
if (FTransform StandPointTransform; BaseActor->GetStandPointTransform(StandPointTransform))
{
return StandPointTransform;
}
}
return Anchor->GetActorTransform();
}

View File

@@ -0,0 +1,38 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
class AActor;
/**
* Where an NPC should stand when a schedule sends it at an actor.
*
* A schedule names the furniture an NPC is meant to use, and the useful place to stand is almost never
* the furniture's own origin. A bench, a bed, a counter: each one carves the navmesh away underneath
* itself, so its origin sits over that hole rather than over floor, and a walk aimed at it is a walk to
* a point no polygon covers. The engine will not project such a destination onto the mesh from further
* away than its own 50 uu window, which is narrower than most of this furniture is wide, so the walk is
* refused outright and the NPC never leaves where it stands.
*
* The place to stand is therefore authored, see AEleriBaseActor::StandPoint, and this is where the
* schedule reads it. It lives in a helper rather than at either reader because there are two of them:
* FResolvedNpcAction::GetAnchorTransform answers for an anchor the caller can see, and
* UScheduleSubsystem::ResolveAction remembers the answer for one that has streamed out. A rule written
* twice is a rule that can come out differently, and an anchor that is walked to one way while it is
* loaded and another way once it comes back is exactly the fault this is here to prevent.
*/
namespace EleriStandPoint
{
/** Where to stand for Anchor: its authored stand point when it has one, and its own transform when
* it does not, which is every anchor that is not furniture. An actor with no stand point of its own
* is an anchor all the same, and is answered for by its transform exactly as it was before stand
* points existed. A null anchor answers with the identity transform, an unset place to be, which is
* what a caller with no actor has; every caller here has already established that its anchor exists.
*
* A pure read, deliberately: nothing is cached and nothing is written back to the anchor, so a bed
* whose stand point arrow is moved while an NPC is walking to it is followed to the new place on
* the next read, the same way the bed itself is. */
PROJECTELERI_API FTransform ResolveAnchorTransform(const AActor* Anchor);
}

View File

@@ -28,6 +28,31 @@ ARemovableStaticMeshActor::ARemovableStaticMeshActor()
OverlapBoxComponent->SetCanEverAffectNavigation(false);
}
void ARemovableStaticMeshActor::PostInitProperties()
{
Super::PostInitProperties();
EleriPersistentId::Ensure(PersistentId, *this);
}
void ARemovableStaticMeshActor::PostLoad()
{
Super::PostLoad();
// Nothing is marked dirty here on purpose: the editor suppresses that while a package is loading.
// What this heals is the running session, so that an instance whose level data predates the
// property still has the one id everything else in this session refers to. Making it permanent
// takes a save of the level.
EleriPersistentId::Ensure(PersistentId, *this);
}
void ARemovableStaticMeshActor::PostActorCreated()
{
Super::PostActorCreated();
EleriPersistentId::Ensure(PersistentId, *this);
}
void ARemovableStaticMeshActor::OnConstruction(const FTransform& Transform)
{
Super::OnConstruction(Transform);
@@ -198,3 +223,70 @@ void ARemovableStaticMeshActor::ReportGridFootprint()
CellsX * Pitch - Size.X, CellsY * Pitch - Size.Y, FillScale);
}
void ARemovableStaticMeshActor::RegeneratePersistentId()
{
const FGuid PreviousId = PersistentId;
// Modified before the write, so the regeneration is an edit of this actor that undo can step back
// out of, like any other change made from the details panel.
Modify();
EleriPersistentId::Regenerate(PersistentId, *this);
MarkPackageDirty();
UE_LOG(LogTemp, Display, TEXT("%s: PersistentId %s -> %s"), *GetActorNameOrLabel(),
PreviousId.IsValid() ? *PreviousId.ToString(EGuidFormats::DigitsWithHyphens) : TEXT("(none)"),
*PersistentId.ToString(EGuidFormats::DigitsWithHyphens));
// The old id is what the save already has a record for, so a second actor still on it would have
// that record restored onto it as well.
const int32 RemainingClaimants = EleriPersistentId::CountOtherClaimants(*this, PreviousId);
if (RemainingClaimants > 0)
{
UE_LOG(LogTemp, Warning, TEXT("%s: %d other actor(s) in this level still claim the id it had. A save that stored that id restores it onto one of them as well, so reassign it."),
*GetActorNameOrLabel(), RemainingClaimants);
}
}
void ARemovableStaticMeshActor::ReportPersistentId()
{
// Read once, because every branch below reports it and an invalid id is a state to be reported
// rather than something to count.
const FString IdText = PersistentId.IsValid()
? PersistentId.ToString(EGuidFormats::DigitsWithHyphens)
: TEXT("(none)");
const int32 Claimants = EleriPersistentId::CountOtherClaimants(*this, PersistentId);
if (!PersistentId.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("%s: PersistentId %s. Its record in the save falls back to matching on the actor name, which a spawned instance does not keep. Reopen the level or press Regenerate Persistent Id."),
*GetActorNameOrLabel(), *IdText);
return;
}
// -1 is "no world to look in", which is not a clean bill of health and must not be reported as one.
if (Claimants < 0)
{
UE_LOG(LogTemp, Warning, TEXT("%s: PersistentId %s. This actor is not in a world, so nothing could be checked against it."),
*GetActorNameOrLabel(), *IdText);
return;
}
if (Claimants == 0)
{
UE_LOG(LogTemp, Display, TEXT("%s: PersistentId %s, claimed by no other actor in this level."),
*GetActorNameOrLabel(), *IdText);
return;
}
UE_LOG(LogTemp, Warning, TEXT("%s: PersistentId %s is ALSO claimed by %d other actor(s) in this level. A save that stores it restores the same position and visibility onto all of them, so give all but one of them an id of their own."),
*GetActorNameOrLabel(), *IdText, Claimants);
}
#if WITH_EDITOR
EDataValidationResult ARemovableStaticMeshActor::IsDataValid(FDataValidationContext& Context) const
{
return EleriPersistentId::ValidateId(*this, PersistentId, Context, Super::IsDataValid(Context));
}
#endif

View File

@@ -4,10 +4,12 @@
#include "CoreMinimal.h"
#include "Engine/StaticMeshActor.h"
#include "Misc/DataValidation.h"
#include "../Public/InventoryComponent.h"
#include "Components/InteractionWidgetComponent.h"
#include "Components/BoxComponent.h"
#include "Interface/InteractableActorInterface.h"
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
#include "RemovableStaticMeshActor.generated.h"
@@ -20,7 +22,7 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnPickup);
*
*/
UCLASS(BlueprintType)
class PROJECTELERI_API ARemovableStaticMeshActor : public AStaticMeshActor, public IInteractableActorInterface
class PROJECTELERI_API ARemovableStaticMeshActor : public AStaticMeshActor, public IInteractableActorInterface, public IActorPersistentIdInterface
{
GENERATED_BODY()
@@ -30,11 +32,42 @@ public:
virtual void BeginPlay() override;
/** Gives a freshly constructed actor an id, including the ones a spawner creates straight out of
* constructor scope rather than loading. */
virtual void PostInitProperties() override;
/** The path a level placed instance's id arrives through, and the one that repairs placed object
* data written before the id was recorded for it. */
virtual void PostLoad() override;
/** The path an actor placed from the inventory takes. It is spawned, never loaded and never placed
* in the level by an author, so this is the only moment it can be given an id, and the only thing
* that keeps its record in the save from being keyed on a name that is reissued by spawn order. */
virtual void PostActorCreated() override;
virtual void OnConstruction(const FTransform& Transform) override;
virtual void ToggleInteractableUI_Implementation(bool bActive) override;
virtual bool IsInteractable_Implementation() const override;
//----- IActorPersistentIdInterface -----
virtual FGuid GetPersistentId() const override { return PersistentId; }
virtual void SetPersistentId(const FGuid& InPersistentId) override { PersistentId = InPersistentId; }
/** Hands this actor a new id and logs the one it had, for the case the id has to be broken on
* purpose: an actor pasted into the level in the editor keeps its source's id, and two actors
* claiming one id is a save that restores the second one onto the first. */
UFUNCTION(BlueprintCallable, CallInEditor, Category = "ID")
void RegeneratePersistentId();
/** Logs this actor's id and says whether anything else in the level claims it. */
UFUNCTION(BlueprintCallable, CallInEditor, Category = "ID")
void ReportPersistentId();
#if WITH_EDITOR
virtual EDataValidationResult IsDataValid(FDataValidationContext& Context) const override;
#endif
UPROPERTY()
bool bFromInventory;
@@ -144,4 +177,12 @@ public:
void K2_TogglePlaceableEffects(bool bIsPlaceable);
protected:
/** Stable identity for this placed object. It is what files its record in the save under something
* a level and a session agree on, instead of under a name whose number depends on the order things
* were spawned in. Serialized with the placed instance rather than rolled in the constructor, see
* EleriPersistentId for why the constructor is the one place it must not be set. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "ID")
FGuid PersistentId;
};

View File

@@ -2,80 +2,581 @@
#include "ExtendedPawnMovement.h"
#include "AIController.h"
#include "CollisionQueryParams.h"
#include "Engine/World.h"
#include "GameFramework/Pawn.h"
#include "Navigation/PathFollowingComponent.h"
#include "NavigationData.h"
#include "NavigationSystem.h"
DEFINE_STAT(STAT_ExtendedPawnMovement_Tick);
UExtendedPawnMovement::UExtendedPawnMovement() {
UExtendedPawnMovement::UExtendedPawnMovement()
{
}
void UExtendedPawnMovement::RequestPathMove(const FVector& MoveInput) {
RequestDirectMove(MoveInput, false);
AAIController* UExtendedPawnMovement::GetAIController() const
{
// Only the pawn knows who possesses it, and a pawn nobody possesses has nobody to be walked by.
// Cast rather than a plain read of the controller, because the pawn could just as well be driven by
// a player controller, and a player controller has no path following to hand a destination to.
return PawnOwner != nullptr ? Cast<AAIController>(PawnOwner->GetController()) : nullptr;
}
void UExtendedPawnMovement::StartMoveToLocation(const FVector& TargetLocation, float Speed, float AcceptanceRadius)
{
// Resolved once here rather than once a tick below: a speed of zero is the caller saying "as fast as
// this pawn goes", which is the answer whenever nobody cares how fast an NPC walks, and a radius
// that was left out means the same for how close counts as arrived.
if (Speed > 0.f)
{
// The speed of the walk is the movement component's own MaxSpeed, because that is what the
// default path following has its velocity clamped to. A caller with a speed of its own therefore
// has it written here rather than kept somewhere of our own that nothing else would respect.
MaxSpeed = Speed;
}
void UExtendedPawnMovement::TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
MoveTargetAcceptanceRadius = AcceptanceRadius >= 0.f ? AcceptanceRadius : DefaultAcceptanceRadius;
if (!bHasMoveTarget || FVector::DistSquared(TargetLocation, MoveTargetLocation) >
FMath::Square(MoveTargetTolerance))
{
// A new journey: what is known about the last attempt says nothing about this one, including
// whatever was said about where it was going.
bMoveRequestIssued = false;
bObservedPathFollowing = false;
bReportedDestinationProjection = false;
}
else if (bMoveRequestIssued)
{
// The same walk, still in the controller's hands: asking again would put the pawn back at the
// start of the path it is halfway along. Nothing else about it has changed either, because a
// destination within the tolerance of the one being walked to is the same place.
return;
}
MoveTargetLocation = TargetLocation;
bHasMoveTarget = true;
// Kept whether or not anybody can walk it, because HasMoveTarget and HasReachedMoveTarget are asked
// about the walk the caller asked for rather than about what a controller made of it.
AAIController* AIController = GetAIController();
if (AIController == nullptr)
{
// Nobody to walk this pawn, and nothing here walks a pawn itself. The destination stays set so
// that the caller can still ask about it, and HasReachedMoveTarget answers for it: a pawn that
// cannot be walked anywhere is already wherever it is going to be.
return;
}
// The whole of the walk, handed to the engine: the path query, the route across the navmesh, the
// avoidance of whatever the navmesh knows about and the steering along it are all this call's.
// Projected, because a destination authored at a bench or a bed sits slightly off the navmesh and is
// still somewhere worth walking to, and partial paths are allowed, because getting most of the way
// to a destination that is partly blocked is more use to an NPC than not setting off at all.
//
// Placed on the navmesh first, by a wider window than the engine's own projection uses. That window
// is the nav data's default query extent, 50 uu either side and 250 uu up and down, which is
// narrower than a bench is: a destination authored at one is further than that from the nearest
// polygon because the bench has carved the navmesh away around it, so the engine finds nothing there
// and refuses the request outright, leaving an NPC that was meant to sit down standing where it
// started. Handing it a destination already on the mesh instead gives it something to arrive at, and
// a destination this could not place either is left to its own projection, which is the same
// treatment as before.
FVector WalkDestination = MoveTargetLocation;
const EDestinationProjection Projection = ProjectDestinationToNavigation(MoveTargetLocation, WalkDestination);
if (Projection != EDestinationProjection::OnMesh && !bReportedDestinationProjection)
{
// Said once per journey rather than once a tick, whatever there is to say. The StateTree
// re-arms an NPC with its destination every tick, so a destination that had to be placed
// somewhere other than where it was authored, or could not be placed at all, would otherwise
// log this per tick for as long as it took somebody to notice, and a walk that quietly goes
// nowhere is the hardest kind of this to tell from an NPC that is thinking.
ReportDestinationProjection(MoveTargetLocation, Projection);
bReportedDestinationProjection = true;
}
const EPathFollowingRequestResult::Type Result = AIController->MoveToLocation(
WalkDestination,
MoveTargetAcceptanceRadius,
/*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true,
// Asked of the engine only when nothing here could place the destination. A destination this has
// already placed is on the mesh, and handing it back to have it projected a second time would be
// asking the engine to find a point it would not have found at all.
/*bProjectDestinationToNavigation=*/Projection == EDestinationProjection::NotOnMesh,
/*bCanStrafe=*/false,
nullptr,
/*bAllowPartialPath=*/true);
// A request that was taken is one the controller is now busy with: accepting one leaves it either
// following a path already or waiting on the path query behind it, and never idle. Anything else is
// a move it would not take at all, which is the end of this walk as far as anybody can tell.
bMoveRequestIssued = Result == EPathFollowingRequestResult::Type::RequestSuccessful;
bObservedPathFollowing = false;
}
EDestinationProjection UExtendedPawnMovement::ProjectDestinationToNavigation(const FVector& Location,
FVector& OutProjectedLocation) const
{
UNavigationSystemV1* NavigationSystem = FNavigationSystem::GetCurrent<UNavigationSystemV1>(GetWorld());
if (NavigationSystem == nullptr)
{
// No navmesh in this world to place anything on, so there is nothing to say about this
// destination and nothing is changed about it. A pawn in a world with no navigation is a case
// this component already answers for elsewhere, see the missing controller below.
return EDestinationProjection::NotOnMesh;
}
// Our own extent rather than the engine's default query extent, which is 50 uu either side and 250
// uu up and down. A bench is wider than that, so a destination in the middle of the hole the bench
// leaves is outside the window and reads as nowhere instead of as the floor beside it. Generous on
// purpose: what this finds is still checked by the path query behind the move request, so a goal
// that turns out to be too far to reach is a partial path, which is more use to an NPC than no
// request at all.
const FVector QueryExtent(DestinationProjectionRadius, DestinationProjectionRadius, DestinationProjectionHeight);
// Asked for with this pawn's own agent properties, so that the destination is placed on the navmesh
// this pawn is actually going to be walked on. A pawn with nav data of its own, a flying agent or
// simply a wider one, has mesh of its own, and a destination placed on somebody else's mesh is a
// walk to a place this pawn cannot stand.
FNavLocation ProjectedLocation;
if (NavigationSystem->ProjectPointToNavigation(Location, ProjectedLocation, QueryExtent,
&GetNavAgentPropertiesRef()))
{
OutProjectedLocation = ProjectedLocation.Location;
return EDestinationProjection::OnMesh;
}
// A second attempt, at the height of this pawn's own feet rather than at the height the destination
// was authored at. A destination is authored where the thing it names is, and a thing inside a
// building has floor under it that the mesh outside that building does not reach: read at its own
// height such a destination is nowhere the navmesh goes, while the same X and Y at the height of the
// floor this pawn is standing on is floor it can be walked to. The point below the destination, in
// other words, which is as close to a bench inside a building as a walk is ever going to get.
//
// The pawn's own feet rather than the last height it was grounded at, because a pawn being walked is
// a pawn on the ground, and the one case where those differ is a pawn in mid-air, which has no floor
// of its own to offer either way.
if (UpdatedComponent != nullptr)
{
const FVector FloorLocation(Location.X, Location.Y,
UpdatedComponent->GetComponentLocation().Z - GetStandingHeight());
if (NavigationSystem->ProjectPointToNavigation(FloorLocation, ProjectedLocation, QueryExtent,
&GetNavAgentPropertiesRef()))
{
OutProjectedLocation = ProjectedLocation.Location;
return EDestinationProjection::OnFloorUnderPawn;
}
}
return EDestinationProjection::NotOnMesh;
}
void UExtendedPawnMovement::ReportDestinationProjection(const FVector& Location,
const EDestinationProjection Projection) const
{
UNavigationSystemV1* NavigationSystem = FNavigationSystem::GetCurrent<UNavigationSystemV1>(GetWorld());
// What this pawn's own agent properties resolve to on this world's navigation, looked up rather than
// assumed. No nav data at all is a world with no navigation built in it; nav data with nothing under
// the destination is a world whose mesh does not cover this corner of it. Two entirely different
// faults with a great deal in common from the outside, which is the whole reason for looking.
const ANavigationData* NavData = nullptr;
if (NavigationSystem != nullptr)
{
NavData = NavigationSystem->GetNavDataForProps(GetNavAgentPropertiesRef());
}
FString NavDataText = TEXT("nothing at all");
if (NavData != nullptr)
{
NavDataText = FString::Printf(TEXT("'%s' of class %s"), *GetNameSafe(NavData), *NavData->GetClass()->GetName());
}
// Wide open, so that what comes back is how far away the nearest navmesh there is rather than
// another nothing at all. Nothing is placed with this window, and nothing that comes out of it is
// used for anything but the message below.
const FVector DiagnosticExtent(ProjectionDiagnosticRadius, ProjectionDiagnosticRadius, ProjectionDiagnosticRadius);
FNavLocation NearestLocation;
const bool bFoundNearby = NavigationSystem != nullptr
&& NavigationSystem->ProjectPointToNavigation(Location, NearestLocation, DiagnosticExtent, NavData);
FString NearestText;
if (bFoundNearby)
{
NearestText = FString::Printf(TEXT("the nearest polygon is %.0f uu away across the ground and %.0f uu in height"),
FVector::Dist2D(Location, NearestLocation.Location), NearestLocation.Location.Z - Location.Z);
}
else
{
NearestText = FString::Printf(TEXT("the nearest polygon is more than %g uu away in every direction"),
ProjectionDiagnosticRadius);
}
// Whether this pawn's own feet are on the mesh, asked with the engine's own window so that the
// answer is the one the engine would give. A pawn standing where the mesh does not reach and a pawn
// whose destination is only at the wrong height fail the projection above in the same way, and this
// is what tells the two of them apart.
FNavLocation PawnLocation;
const bool bPawnOnMesh = NavigationSystem != nullptr && UpdatedComponent != nullptr
&& NavigationSystem->ProjectPointToNavigation(UpdatedComponent->GetComponentLocation(), PawnLocation,
INVALID_NAVEXTENT, NavData);
const FString PawnText = bPawnOnMesh ? TEXT("is standing on it") : TEXT("is not standing on it either");
const FString Facts = FString::Printf(TEXT("Agent properties resolve to %s; the pawn %s; and %s."),
*NavDataText, *PawnText, *NearestText);
if (Projection == EDestinationProjection::OnFloorUnderPawn)
{
// Not a refusal: the walk is happening, just not at the height the destination was authored at.
// Whoever put the anchor where it is wants to know that the navmesh does not come up to it.
UE_LOG(LogTemp, Warning,
TEXT("%s: the destination (%s) is not on the navmesh at the height it was authored at, so the "
"walk is being asked for at the point below it, at the height of this pawn's own feet. %s"),
*GetNameSafe(PawnOwner), *Location.ToCompactString(), *Facts);
return;
}
UE_LOG(LogTemp, Warning,
TEXT("%s: the destination (%s) is not on the navmesh: there is none within %g uu of it either side "
"or %g uu above or below it, so nothing can place it and the destination is asked for as "
"authored. The engine's own projection searches a narrower window than that, so it is not "
"expected to place it either. %s"),
*GetNameSafe(PawnOwner), *Location.ToCompactString(), DestinationProjectionRadius,
DestinationProjectionHeight, *Facts);
}
void UExtendedPawnMovement::StopMoving()
{
bHasMoveTarget = false;
bMoveRequestIssued = false;
bObservedPathFollowing = false;
// Aborted rather than merely forgotten, because a controller left to itself would keep walking the
// pawn towards the destination it has just been told to forget.
if (AAIController* AIController = GetAIController())
{
AIController->StopMovement();
}
// Dropped here as well as by the controller, so that a pawn asked to stop between two ticks is
// standing still immediately rather than on the next update. The base class moves a pawn by nothing
// but this velocity, and it is zero from here until somebody asks for a walk again.
Velocity = FVector::ZeroVector;
}
bool UExtendedPawnMovement::HasReachedMoveTarget() const
{
if (!bHasMoveTarget || UpdatedComponent == nullptr)
{
return false;
}
// Flat distance. Which height the destination was authored at is the gravity's business, and a
// point sitting slightly inside the floor should not read as "not there yet" forever.
FVector ToTarget = MoveTargetLocation - UpdatedComponent->GetComponentLocation();
ToTarget.Z = 0.f;
if (ToTarget.SizeSquared() <= FMath::Square(MoveTargetAcceptanceRadius))
{
return true;
}
// Somewhere else entirely with nobody having taken the walk on: either this pawn has no controller
// at all, or the controller would not take the destination, and either way there is no walk left to
// wait for. Saying so is what leaves the caller to carry on where the pawn stands rather than
// waiting forever on somebody who is never going to move it.
if (!bMoveRequestIssued)
{
return true;
}
const AController* Controller = PawnOwner != nullptr ? PawnOwner->GetController() : nullptr;
if (Controller == nullptr || !bObservedPathFollowing)
{
return false;
}
// The controller was walking this pawn and has now finished with it: it either got there or gave up
// on a destination nothing reaches, and both are the end of the walk as far as anybody outside this
// component is concerned. Standing still waiting for a path that is not coming helps nobody.
return !Controller->IsFollowingAPath();
}
bool UExtendedPawnMovement::IsMovingToTarget() const
{
return bHasMoveTarget && !HasReachedMoveTarget();
}
void UExtendedPawnMovement::TickComponent(float DeltaTime, enum ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
SCOPE_CYCLE_COUNTER(STAT_ExtendedPawnMovement_Tick);
if (ShouldSkipUpdate(DeltaTime))
{
return;
}
// The base class is what actually moves a possessed pawn, and it moves it from the velocity the AI
// controller's path following asks it for: it turns that into a swept step and slides along
// whatever it runs into. Nothing here steers a pawn or moves one by itself, and for a pawn with no
// controller the base class does nothing at all, which is the whole of that promise.
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
/*Velocity = FVector::ZeroVector;
if (!RequestedVelocity.IsNearlyZero())
Velocity += RequestedVelocity;*/
float VerticalVelocity = 0.f;
if (PawnOwner && UpdatedComponent && bShouldApplyGravity) {
FHitResult OutHit;
bIsGrounded = TickGroundDetection(OutHit);
if (!bIsGrounded)
{
VerticalVelocity = GravityScale * 100.f;
}
else {
VerticalVelocity = 0;
}
if (PawnOwner == nullptr || UpdatedComponent == nullptr)
{
return;
}
if (!Velocity.IsNearlyZero()) {
const FRotator CurrentRotation = UpdatedComponent->GetComponentRotation();
FRotator TargetRotation = Velocity.ToOrientationRotator();
TargetRotation.Pitch = 0.f;
TargetRotation.Roll = 0.f;
const FRotator NewRotation = FMath::RInterpTo(
CurrentRotation,
TargetRotation,
DeltaTime,
RotationSpeed / 360.f // normalize speed
);
UpdatedComponent->SetWorldRotation(NewRotation);
// Noticed here rather than in HasReachedMoveTarget, because this runs every frame while that is
// only ever asked now and then, and the answer has to have been seen at least once for the
// difference between a walk that has ended and one that has not begun to be readable at all.
const AController* Controller = PawnOwner->GetController();
if (bMoveRequestIssued && Controller != nullptr && Controller->IsFollowingAPath())
{
bObservedPathFollowing = true;
}
FVector FinalVelocity = Velocity;
FinalVelocity.Z += VerticalVelocity;
MoveUpdatedComponent(FinalVelocity * DeltaTime, UpdatedComponent->GetComponentRotation(), true);
ApplyGravity(DeltaTime);
FaceVelocity(DeltaTime);
}
void UExtendedPawnMovement::GetFloorTraceEnds(float DistanceBelowFeet, FVector& OutStart, FVector& OutEnd) const
{
// The middle of the pawn, down to its feet and then however far past them the caller asked for.
OutStart = UpdatedComponent->GetComponentLocation();
OutEnd = OutStart - FVector(0.f, 0.f, GetStandingHeight() + FMath::Max(DistanceBelowFeet, 0.f));
}
float UExtendedPawnMovement::GetStandingHeight() const
{
if (UpdatedComponent == nullptr)
{
return 0.f;
}
// The bounds are the pawn's own rather than a height this code assumed, so a pawn of any size
// measures its own height and every trace in here shares one answer about where its feet are.
return UpdatedComponent->GetLocalBounds().GetBox().GetSize().Z / 2.f;
}
bool UExtendedPawnMovement::SweepForFloor(float DistanceBelowFeet, FHitResult& OutHit) const
{
if (UpdatedComponent == nullptr)
{
return false;
}
FVector Start, End;
GetFloorTraceEnds(DistanceBelowFeet, Start, End);
FCollisionQueryParams Params(SCENE_QUERY_STAT(NpcGroundCheck), false, GetOwner());
bool UExtendedPawnMovement::TickGroundDetection(const FHitResult& OutHit) {
//Trace for on ground
FHitResult Hit;
FVector Start = UpdatedComponent->GetComponentLocation();
float Scale = UpdatedComponent->GetLocalBounds().GetBox().GetSize().Z / 2.f;
FVector End = Start + FVector(0, 0, -Scale); // small downward trace
FCollisionQueryParams Params;
Params.AddIgnoredActor(GetOwner());
//DrawDebugLine(GetWorld(), Start, End, FColor::Red);
//DrawDebugSphere(GetWorld(), End, 30.f, 16, FColor::Blue);
return GetWorld()->SweepSingleByChannel(
OutHit,
Start,
End,
FQuat::Identity,
ECC_Visibility,
FCollisionShape::MakeSphere(GroundTraceRadius),
Params
);
}
FVector UExtendedPawnMovement::GetStandingLocationFor(const FVector& Location) const
{
if (UpdatedComponent == nullptr || GetWorld() == nullptr)
{
return Location;
}
// From where the middle of a standing pawn would be, down to where its feet would be. A location
// authored at ground level, which is what the origin of a bench or a bed is, is the case that
// wants this most: its height is the floor itself, so the trace has to start a whole pawn above it
// for there to be anything left to find.
const float StandingHeight = GetStandingHeight();
const FVector Start = Location + FVector(0.f, 0.f, StandingHeight);
const FVector End = Location - FVector(0.f, 0.f, StandingHeight);
FCollisionQueryParams Params(SCENE_QUERY_STAT(NpcStandingSpot), false, GetOwner());
FHitResult Hit;
if (!GetWorld()->SweepSingleByChannel(
Hit,
Start,
End,
FQuat::Identity,
ECC_Visibility,
FCollisionShape::MakeSphere(30.f), // or use a box/capsule
FCollisionShape::MakeSphere(GroundTraceRadius),
Params
);
))
{
// Nothing down there to stand on, so the caller's own answer is the only one that can be
// defended and no height is invented for it.
return Location;
}
// Another pawn is not a floor, the same way it is not one below. Two NPCs sent to the same spot
// would otherwise have this one standing on the head of the other.
const AActor* HitActor = Hit.GetActor();
if (HitActor != nullptr && Cast<APawn>(HitActor) != nullptr)
{
return Location;
}
// The surface that was hit rather than the point the sphere came to rest at, so that the feet end
// up on the floor instead of GroundTraceRadius above it.
return FVector(Location.X, Location.Y, Hit.ImpactPoint.Z + StandingHeight);
}
bool UExtendedPawnMovement::IsWorldBelow(float DistanceBelowFeet) const
{
if (UpdatedComponent == nullptr || GetWorld() == nullptr)
{
return false;
}
FVector Start, End;
GetFloorTraceEnds(DistanceBelowFeet, Start, End);
FCollisionQueryParams Params(SCENE_QUERY_STAT(NpcFloorProbe), false, GetOwner());
TArray<FHitResult> Hits;
if (!GetWorld()->SweepMultiByChannel(Hits, Start, End, FQuat::Identity, ECC_Visibility,
FCollisionShape::MakeSphere(GroundTraceRadius), Params))
{
return false;
}
// Another pawn standing beside this one is not a floor. A frozen NPC carries no collision and so
// never turns up here, but an awake one does, and answering "there is ground down there" because
// of a neighbour would be the wrong answer to the question this trace is really asking.
for (const FHitResult& Hit : Hits)
{
const AActor* HitActor = Hit.GetActor();
if (HitActor == nullptr || Cast<APawn>(HitActor) == nullptr)
{
return true;
}
}
return false;
}
void UExtendedPawnMovement::RiseBackToLastGroundedZ()
{
if (!bHasLastGroundedZ || UpdatedComponent == nullptr)
{
return;
}
const float Rise = LastGroundedZ - UpdatedComponent->GetComponentLocation().Z;
if (Rise <= 0.f)
{
// Already at or above the height it stood at, so there is nothing to put back.
return;
}
// Swept, so a ceiling or anything else in the way stops the pawn rather than being pushed
// through, and at most one probe distance in a single step. It only ever happens while the floor
// is missing, so an intact level never pays for it at all.
// Safe for the same reason the walking move is: a pawn whose floor has gone away is very often
// overlapping whatever stands where the floor used to be, and a plain swept move would be refused.
FHitResult RiseHit;
SafeMoveUpdatedComponent(FVector(0.f, 0.f, Rise), UpdatedComponent->GetComponentQuat(), true, RiseHit);
}
void UExtendedPawnMovement::ApplyGravity(float DeltaTime)
{
if (!bShouldApplyGravity)
{
return;
}
FHitResult FloorHit;
bIsGrounded = SweepForFloor(0.f, FloorHit);
float VerticalVelocity = 0.f;
if (bIsGrounded)
{
// Remembered while it is true, which is the only moment it can be: the height worth going
// back to is the one the pawn was last actually standing on. Nothing else is done, because a
// pawn the floor is already holding up does not need to be put anywhere.
LastGroundedZ = UpdatedComponent->GetComponentLocation().Z;
bHasLastGroundedZ = true;
return;
}
if (PawnOwner->GetController() == nullptr && !IsWorldBelow(GroundProbeDistance))
{
// Nothing under this pawn at all, which is what a floor that has been streamed away looks
// like from here. There is no floor to land on, gravity would keep feeding it downward
// velocity for as long as the cell is missing, and nothing else would ever notice: an NPC has
// no controller to catch it and it never streams out itself, so it would fall out of the world
// and stay gone. So it holds the last height it actually stood on instead, and gravity comes
// back on its own the moment there is floor again.
//
// This is deliberately only ever asked of a pawn with no controller. A possessed pawn is
// walked by a controller, which is watched from outside and is put back where it belongs by
// whatever owns it, so nothing here is allowed to second guess it.
RiseBackToLastGroundedZ();
return;
}
VerticalVelocity = GravityScale * 100.f;
// Downward, and only downward: the base class has already made the step the controller asked for
// on this tick, and moving the pawn by its whole velocity a second time here would walk it twice
// as far as it was told to. Safe rather than a plain move, because a pawn put down onto a
// destination authored at ground level stands inside the floor, and a plain swept move would be
// refused for as long as it stayed there.
FHitResult MoveHit;
SafeMoveUpdatedComponent(FVector(0.f, 0.f, VerticalVelocity * DeltaTime),
UpdatedComponent->GetComponentQuat(), true, MoveHit);
}
void UExtendedPawnMovement::FaceVelocity(float DeltaTime)
{
if (RotationSpeed <= 0.f || UpdatedComponent == nullptr)
{
return;
}
// Flat, because a pawn turning to face up a hill would be lying on its face a step into it, and
// the sprite on it only ever shows one of four directions anyway.
FVector FlatVelocity = Velocity;
FlatVelocity.Z = 0.f;
if (FlatVelocity.IsNearlyZero())
{
// Standing still has no direction to face, and turning to face the last one it had would have
// a pawn that has just stopped carry on rotating on the spot.
return;
}
const FRotator CurrentRotation = UpdatedComponent->GetComponentRotation();
FRotator TargetRotation = FlatVelocity.ToOrientationRotator();
TargetRotation.Pitch = 0.f;
TargetRotation.Roll = 0.f;
// Interpolated rather than snapped, so that a pawn turning a corner turns through it instead of
// spinning on the spot, and against RotationSpeed per second rather than against a fixed fraction
// of a frame, so that the turn takes the same time whatever the frame rate is.
const FRotator NewRotation = FMath::RInterpTo(
CurrentRotation,
TargetRotation,
DeltaTime,
RotationSpeed / 360.f // normalize speed
);
UpdatedComponent->SetWorldRotation(NewRotation);
}

View File

@@ -7,11 +7,51 @@
#include "ExtendedPawnMovement.generated.h"
DECLARE_STATS_GROUP(TEXT("ExtendedPawnMovement"), STATGROUP_ExtendedPawnMovement, STATCAT_Advanced);
DECLARE_CYCLE_STAT_EXTERN(TEXT("ExtendedPawnMovement"), STAT_ExtendedPawnMovement_Tick, STATGROUP_ExtendedPawnMovement, );
DECLARE_CYCLE_STAT_EXTERN(TEXT("ExtendedPawnMovement"), STAT_ExtendedPawnMovement_Tick,
STATGROUP_ExtendedPawnMovement,);
class AAIController;
/** How a destination came out of UExtendedPawnMovement::ProjectDestinationToNavigation. Three answers
* rather than the two a bool gives, because where a destination had to be put is worth saying out loud:
* a walk that only works because of the second attempt is a walk whose destination is authored
* somewhere the navmesh does not reach, and that is a thing for whoever authored it to hear about. */
enum class EDestinationProjection : uint8
{
/** On the navmesh at the height it was authored at, or close enough to it to be the same place. */
OnMesh,
/** Not reachable at the height it was authored at, and placed instead at the destination's own X
* and Y and at the height of this pawn's own feet, which is the floor it is standing on. */
OnFloorUnderPawn,
/** Nowhere within the window a destination is placed with. */
NotOnMesh,
};
/**
*
* Movement for every pawn in the game that is not the player: gravity and a turn towards the way it
* is going on top of UFloatingPawnMovement, so that a sprite on a capsule faces the direction it is
* walking in without a skeletal mesh to do it for it.
*
* It walks nothing itself. A pawn wearing one of these is possessed by an AIController, and where it
* goes is that controller's business: StartMoveToLocation hands it a destination with
* AAIController::MoveToLocation, and the navmesh, the path query, the route around whatever stands in
* the way and the steering along it are all the engine's own. What is left here are the two things a
* floating pawn has no answer for on its own, which are standing on the floor and turning to face
* where it is going, along with the bookkeeping that lets a caller ask whether the walk it asked for
* is over yet.
*
* The walk API below is the same one the StateTree task and the state machine have always called, so
* that neither of them has to know it is an AIController on the other end of it. Speed is the
* component's own MaxSpeed, which is what the default path following clamps the velocity it asks for
* to, so a caller with a speed of its own simply has it written there. Arriving is the controller's
* answer as well: the walk is over once the controller has finished with it.
*
* A pawn with no controller cannot be walked by anybody. This says so instead of inventing a second
* way to move it, and a destination handed to such a pawn reads as reached on the spot, which leaves
* its caller free to get on with whatever it was going to do there.
*/
UCLASS()
class PROJECTELERI_API UExtendedPawnMovement : public UFloatingPawnMovement
@@ -20,18 +60,61 @@ class PROJECTELERI_API UExtendedPawnMovement : public UFloatingPawnMovement
public:
UExtendedPawnMovement();
UFUNCTION(BlueprintCallable, BlueprintPure)
bool IsGrounded() const { return bIsGrounded; }
UFUNCTION(BlueprintCallable, BlueprintPure)
bool GetShouldApplyGravity() const { return bShouldApplyGravity; }
UFUNCTION(BlueprintCallable)
void SetShouldApplyGravity(bool bActive) { bShouldApplyGravity = bActive; }
virtual bool CanStartPathFollowing() const override { return true; }
virtual void RequestPathMove(const FVector& MoveInput) override;
protected:
/** Points this pawn at a place and has its AI controller walk it there. Speed of zero walks at
* MaxSpeed and a negative acceptance radius uses DefaultAcceptanceRadius, so a caller that cares
* about neither can pass neither and still get a pawn that walks at a sane speed to within reach
* of the point it was given.
*
* Asking again for the place this pawn is already walking to is the same walk and asks the
* controller for nothing: the StateTree re-arms an NPC with its destination every tick, and a
* fresh request each time would restart the path underneath it every tick. */
UFUNCTION(BlueprintCallable, Category = "Movement|Walk")
void StartMoveToLocation(const FVector& TargetLocation, float Speed = 0.f, float AcceptanceRadius = -1.f);
/** Forgets the destination and drops the pawn to a standstill: whatever the controller was walking
* it through is aborted, and the velocity the base class moves it with goes with it. */
UFUNCTION(BlueprintCallable, Category = "Movement|Walk")
void StopMoving();
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Movement|Walk")
bool HasMoveTarget() const { return bHasMoveTarget; }
/** True while a destination is set and the pawn is not within reach of it yet. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Movement|Walk")
bool IsMovingToTarget() const;
/** True when the pawn is standing within its acceptance radius of the destination, and true as well
* for a destination nothing is walking it to at all, because a pawn that cannot be walked anywhere
* is already wherever it is going to be. False while there is no destination at all, so a caller
* that has not asked for a walk cannot read this as one that has finished. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Movement|Walk")
bool HasReachedMoveTarget() const;
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Movement|Walk")
FVector GetMoveTargetLocation() const { return MoveTargetLocation; }
/** The place a pawn would be standing if it stood at the given location: its X and Y, and a Z that
* puts the middle of the pawn half its own height above whatever floor is under it. A destination
* is authored where the thing it names stands, and for a bench or a bed that is its own origin down
* at ground level, while a pawn's origin is the middle of its capsule: read as written, such a
* destination asks for the bottom half of the NPC to be inside the floor. A location with nothing
* under it is handed back untouched, so a caller is never given a height invented for it. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Movement|Walk")
FVector GetStandingLocationFor(const FVector& Location) const;
protected:
/** How fast a pawn is turned towards the way it is going, in degrees per second. Zero leaves its
* rotation alone, which is what a project that turns its pawns somewhere else wants. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Movement|Rotation")
float RotationSpeed = 720.f;
@@ -44,9 +127,143 @@ protected:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
float GravityScale = -9.8f;
bool TickGroundDetection(const FHitResult& OutHit);
void TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
/** How far below its feet a pawn with no controller looks for floor before concluding that there is
* none at all. Anything up to this distance still falls the way it should, which is what keeps
* steps, kerbs and terraces working; past it the floor is taken to be missing rather than far away,
* because a floor that is missing is what a streamed out cell looks like from here. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Movement|Gravity",
meta = (AllowPrivateAccess = "true", ClampMin = "0.0", UIMin = "0.0"))
float GroundProbeDistance = 1000.f;
FVector RequestedVelocity;
/** How close to its destination a pawn has to be for the walk to be over, used by any caller that
* did not ask for a radius of its own. It has to clear whatever the NPC is walking to, so it is a
* good deal wider than the pawn itself. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Movement|Walk",
meta = (AllowPrivateAccess = "true", ClampMin = "0.0", UIMin = "0.0"))
float DefaultAcceptanceRadius = 100.f;
/** How far a destination has to move before the walk to it counts as a new one. The StateTree
* re-arms an NPC with the same destination every tick, and without this that would be a fresh move
* request every tick, when a destination that has not moved describes the same journey it
* described a tick ago. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Movement|Walk",
meta = (AllowPrivateAccess = "true", ClampMin = "0.0", UIMin = "0.0"))
float MoveTargetTolerance = 100.f;
/** How far to either side of a destination the navmesh is looked for when the destination itself is
* not on it. Wider than the engine's own 50 uu window on purpose: a destination authored at a bench
* or a bed sits in the hole that furniture leaves in the navmesh, and that hole is as wide as the
* furniture is, so a projection that only looks 50 uu either way finds nothing and the move request
* is refused rather than brought to a stop at the closest place that works. See
* ProjectDestinationToNavigation. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Movement|Walk",
meta = (AllowPrivateAccess = "true", ClampMin = "0.0", UIMin = "0.0"))
float DestinationProjectionRadius = 250.f;
/** The same window upwards and downwards. The floor beside a bench is the floor the bench stands on,
* so most of what this has to cover is the height of the furniture, and the closest point wins where
* several are inside the window, which is what keeps the search on the floor the destination was
* authored at rather than on the floor of the room above it. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Movement|Walk",
meta = (AllowPrivateAccess = "true", ClampMin = "0.0", UIMin = "0.0"))
float DestinationProjectionHeight = 250.f;
/** Radius of both of the downward traces below, i.e. how much ground around a pawn's feet counts as
* being under it. */
static constexpr float GroundTraceRadius = 30.f;
/** How far the search behind ReportDestinationProjection looks. Wide on purpose: the point of that
* message is to say how far away the nearest navmesh is, and a window as narrow as the one a
* destination is placed with could only ever report "nothing there either", which is what is
* already known and the reason the message is being written. Nothing is placed with this window. */
static constexpr float ProjectionDiagnosticRadius = 2000.f;
virtual void TickComponent(float DeltaTime, enum ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
/** The destination a walk should actually be asked for: Location itself when it is already on the
* navmesh, and the closest point that is when it is not. Where the navmesh does not come up to the
* height Location was authored at, the floor this pawn is standing on under it is offered instead,
* which is what an anchor inside a building reads as to a pawn standing outside it. NotOnMesh means
* there is no navmesh within DestinationProjectionRadius and DestinationProjectionHeight of Location
* at all, and the destination is then used as authored, because the engine's own projection is still
* free to make something of it, and a cell that has not been streamed in yet answers nothing and
* answers fine a moment later. */
EDestinationProjection ProjectDestinationToNavigation(const FVector& Location, FVector& OutProjectedLocation) const;
/** What to say about a destination that could not be placed on the navmesh where it was authored, or
* could not be placed at all: which nav data this pawn's own agent properties resolve to, whether
* the pawn is on that mesh itself, and how far the nearest navmesh there is from the destination.
* Those three together are what tells a world with no navigation built in it apart from one whose
* mesh does not cover this corner of it, and from the outside the two look identical.
*
* A diagnostic and nothing else: nothing here places a destination and nothing here changes a walk,
* and the window it searches with is ProjectionDiagnosticRadius rather than anything a destination
* is ever placed by. Written once per journey rather than once a tick, see
* bReportedDestinationProjection, which is what makes a message this dense affordable. */
void ReportDestinationProjection(const FVector& Location, EDestinationProjection Projection) const;
/** Traces from the middle of the pawn down to its feet, and then DistanceBelowFeet further, and
* reports whatever it hit. This is the ground check, and it is the same trace the class always
* used: anything up to GroundTraceRadius either side of the pawn's feet counts as floor. */
bool SweepForFloor(float DistanceBelowFeet, FHitResult& OutHit) const;
/** The same trace asking a different question: is there world down there at all. Another pawn is
* not world, so a neighbour standing beside this pawn does not read as ground it could land on. */
bool IsWorldBelow(float DistanceBelowFeet) const;
/** Where the two traces above run from and to, worked out from the pawn's own bounds so that a pawn
* of any size measures its own height rather than a height this code assumed. Shared, so that the
* ground check and the probe can never disagree about where "below the feet" is. */
void GetFloorTraceEnds(float DistanceBelowFeet, FVector& OutStart, FVector& OutEnd) const;
/** Half the height of the thing that is being moved, which is how far its middle stands above the
* ground it is standing on. Read from the component's own bounds, so that a pawn of any size
* measures its own height rather than a height this code assumed. */
float GetStandingHeight() const;
/** Lifts the pawn back up to the last height it actually stood on. Swept, so a ceiling stops it. */
void RiseBackToLastGroundedZ();
/** Keeps this pawn on the floor: the ground check, the fall when there is nothing under it, and the
* holding of the last height it stood at when the floor itself has gone away. Downward only, so
* that the step the base class has already made is never made a second time. */
void ApplyGravity(float DeltaTime);
/** Turns the pawn towards the way it is going, flat, so that a sprite follows the street rather than
* the hill and a pawn is never left lying face down on a slope. */
void FaceVelocity(float DeltaTime);
/** The AI controller walking this pawn, null when nothing possesses it. */
AAIController* GetAIController() const;
/** Where this pawn is walking to */
FVector MoveTargetLocation = FVector::ZeroVector;
/** Radius that went with it, already resolved against the default above. */
float MoveTargetAcceptanceRadius = 0.f;
/** False when there is no walk in progress, which is the state a pawn is in until it is asked. */
bool bHasMoveTarget = false;
/** True while a move request of ours is in the controller's hands. This is what lets
* HasReachedMoveTarget tell a walk that has ended from one that has not been taken on yet. */
bool bMoveRequestIssued = false;
/** True once the controller has been seen actually following the path it was given. Only after that
* does the controller going idle mean the walk is over: before it, idle means the request has not
* been picked up, because a controller sets itself to following a path in the same call that
* accepts one. */
bool bObservedPathFollowing = false;
/** The last height this pawn was standing on, and whether it has ever stood on anything yet. */
float LastGroundedZ = 0.f;
bool bHasLastGroundedZ = false;
/** True once how this destination came out of the navmesh projection has been reported, whether that
* was a placement somewhere other than where it was authored or no placement at all, so that a pawn
* re-armed with the same destination every tick says so once about it rather than once a tick. Held
* with the rest of a journey's state and dropped when the destination changes to a new one, see
* StartMoveToLocation. */
bool bReportedDestinationProjection = false;
};

View File

@@ -0,0 +1,100 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "NpcBaseCharacter.h"
#include "BaseAIController.h"
#include "Components/CapsuleComponent.h"
#include "ProjectEleri/Ai/NpcScheduleComponent.h"
#include "ProjectEleri/Components/EleriFlipbookComponent.h"
#include "ProjectEleri/NPC/ExtendedPawnMovement.h"
#include "ProjectEleri/NPC/NpcStateMachineComponent.h"
#include "ProjectEleri/Public/InventoryComponent.h"
ANpcBaseCharacter::ANpcBaseCharacter()
{
// Nothing in this class wants a call every frame: the flipbook component animates itself, the
// schedule looks at the clock on its own half second interval and the state machine ticks only
// while it is doing something. Staying out of the tick list is most of what makes an NPC cheap.
PrimaryActorTick.bCanEverTick = false;
// Capsule (like ACharacter), and the only collider this pawn has.
Capsule = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
Capsule->InitCapsuleSize(42.f, 96.f);
Capsule->SetCollisionProfileName(TEXT("Pawn"));
Capsule->SetCanEverAffectNavigation(false);
RootComponent = Capsule;
// A flipbook in place of the mesh. Attached with no offset, so its origin sits on the capsule
// centre, which is the same place the player's own sprite sits.
FlipbookComponent = CreateDefaultSubobject<UEleriFlipbookComponent>(TEXT("Flipbook Component"));
FlipbookComponent->SetupAttachment(Capsule);
FlipbookComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// The lightest pawn movement an NPC can have: a UFloatingPawnMovement, so there is no physics and
// no step handling, only a speed and a swept collision, plus the turn towards its own velocity
// that a directional flipbook needs, and the gravity that keeps it standing on the floor. Where it
// walks is not decided here: see the possession below and the class comment.
MovementComponent = CreateDefaultSubobject<UExtendedPawnMovement>(TEXT("Movement Component"));
MovementComponent->UpdatedComponent = Capsule;
// Possessed by this project's own AI controller, because that is what walks an NPC from now on: the
// destination the state machine hands the movement component goes on to the controller, whose path
// following plans the route across the navmesh and moves the pawn along it. Nothing in this class
// or in the movement component moves a pawn anywhere by itself.
//
// A level with no navmesh, or an NPC whose blueprint turns possession off, is one where the NPC
// stands where it was placed and does its activity there: a walk nobody can make reports itself as
// over rather than leaving the NPC waiting on it, which is what UExtendedPawnMovement is for.
AIControllerClass = ABaseAIController::StaticClass();
AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
// This NPC's behaviour, in C++ rather than in a StateTree asset. It is handed an assignment by the
// schedule component on this NPC's first unfreeze, waits for one until then, and has no tick of
// its own while it is inactive.
StateMachineComponent = CreateDefaultSubobject<UNpcStateMachineComponent>(TEXT("Npc State Machine Component"));
ScheduleComponent = CreateDefaultSubobject<UNpcScheduleComponent>(TEXT("Npc Schedule Component"));
InventoryComponent = CreateDefaultSubobject<UInventoryComponent>(TEXT("Inventory 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.
// In UE 5.8 this flag (and SetIsSpatiallyLoaded with it) is editor-only data on AActor, which is
// why it can only be set here and why the guard is needed for a packaged build to compile at
// all. That is enough for the intent to survive cooking: the flag is not read by the packaged
// game, the cook bakes this value into the actor descriptor that World Partition streams from.
#if WITH_EDITORONLY_DATA
bIsSpatiallyLoaded = false;
#endif
WidgetComponent = CreateDefaultSubobject<UInteractionWidgetComponent>(TEXT("WidgetComponent"));
WidgetComponent->SetupAttachment(RootComponent);
}
// Called when the game starts or when spawned
void ANpcBaseCharacter::BeginPlay()
{
Super::BeginPlay();
// There is nothing to start here. The state machine waits to be told what to do by the schedule
// component, which is what keeps an NPC the player never goes near from doing any work at all, and
// an NPC with no schedule component simply stands where it was placed because nothing ever tells
// it about an assignment.
}
void ANpcBaseCharacter::ToggleInteractableUI_Implementation(bool bActive)
{
if (bActive && !IInteractableActorInterface::Execute_IsInteractable(this))
{
return;
}
IInteractableActorInterface::ToggleInteractableUI_Implementation(bActive);
if (WidgetComponent)
{
WidgetComponent->ToggleWidget(bActive);
}
}

View File

@@ -0,0 +1,114 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "Components/InteractionWidgetComponent.h"
#include "GameFramework/Pawn.h"
#include "Interface/InteractableActorInterface.h"
#include "NpcBaseCharacter.generated.h"
class UCapsuleComponent;
class UEleriFlipbookComponent;
class UExtendedPawnMovement;
class UInventoryComponent;
class UNpcScheduleComponent;
class UNpcStateMachineComponent;
USTRUCT(BlueprintType)
struct FNpcCharacterData
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (Categories = "NpcTags"))
FGameplayTag NpcTag;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FText NpcName;
};
/**
* A scheduled NPC: a plain APawn with a capsule, a flipbook and nothing else, so that a town full
* of them stays affordable next to the player's ACharacter.
*
* It deliberately has no skeletal mesh, no character movement and no physics. Movement comes from
* UExtendedPawnMovement, which is a UFloatingPawnMovement rather than a UCharacterMovementComponent,
* and the pawn is possessed by an ABaseAIController: the state machine hands a destination to the
* movement component, which hands it on to the controller, and the path across the navmesh, the route
* around whatever stands in the way and the walking of it are all the engine's default path
* following. What the movement component does itself is keep the pawn on the floor and turn it to
* face the way it is going.
*
* Everything else it owns is a component and each one stands on its own, so the pawn is usable with
* any of them removed. The schedule decides what it should be doing and freezes it while the player
* is far away, the state machine walks it there and keeps it there, the inventory holds whatever it
* carries, and the interaction widget shows its prompt.
*/
UCLASS(BlueprintType)
class PROJECTELERI_API ANpcBaseCharacter : public APawn, public IInteractableActorInterface
{
GENERATED_BODY()
protected:
ANpcBaseCharacter();
public:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Components")
UCapsuleComponent* GetCapsuleComponent() const { return Capsule; }
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Components")
UEleriFlipbookComponent* GetFlipbookComponent() const { return FlipbookComponent; }
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Components")
UInventoryComponent* GetInventoryComponent() const { return InventoryComponent; }
/** The schedule driving this NPC, 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; }
/** The state machine running this NPC's behaviour: where it walks to and what it does once it is
* there. Handed its assignment by the schedule component and driven by nothing else. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Components")
UNpcStateMachineComponent* GetNpcStateMachine() const { return StateMachineComponent; }
virtual void ToggleInteractableUI_Implementation(bool bActive) override;
protected:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components")
TObjectPtr<UCapsuleComponent> Capsule;
/** Everything this NPC is drawn with. No mesh is created at all, so there is no skeleton, no
* anim blueprint and no bone work to pay for. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components")
TObjectPtr<UEleriFlipbookComponent> FlipbookComponent;
/** Walks the NPC wherever its StateTree sends it. Configure the speed on the component or on the
* task that did the sending. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Movement")
TObjectPtr<UExtendedPawnMovement> MovementComponent;
/** 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")
TObjectPtr<UInventoryComponent> InventoryComponent;
/** Runs this NPC's behaviour: walks it to whatever the schedule assigned and does the action
* once it is there. Nothing is assigned to it, because everything it needs arrives from the
* schedule component on this NPC's first unfreeze. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components")
TObjectPtr<UNpcStateMachineComponent> StateMachineComponent;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
TObjectPtr<UInteractionWidgetComponent> WidgetComponent;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FNpcCharacterData NpcCharacterData;
};

View File

@@ -0,0 +1,494 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "NpcStateMachineComponent.h"
#include "ProjectEleri/Ai/NpcScheduleComponent.h"
#include "ProjectEleri/DialogueSystem/DialogueSubsystem.h"
#include "ProjectEleri/NPC/ExtendedPawnMovement.h"
#include "GameFramework/Pawn.h"
namespace
{
/** How often the machine looks at what it is doing. Slower than a frame on purpose: the walk
* itself is the movement component's business and runs every frame, so all this has to notice is
* that the NPC has arrived or that the player has started talking to it, and neither of those is
* worth a tick of its own every frame for every NPC in a town. */
constexpr float StateTickInterval = 0.2f;
/** How close to its destination the NPC is when it counts as facing it, in degrees. Turning is
* read back from the actor every tick, so stopping a fraction short keeps it from jittering
* between two nearly identical yaws. */
constexpr float FacingTolerance = 0.5f;
}
UNpcStateMachineComponent::UNpcStateMachineComponent()
{
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.TickInterval = StateTickInterval;
// Nothing to do until the schedule says otherwise, and an NPC out of range must not be paying for
// a tick that would find nothing to do anyway.
PrimaryComponentTick.bStartWithTickEnabled = false;
}
void UNpcStateMachineComponent::BeginPlay()
{
Super::BeginPlay();
EnsureInitialised();
}
void UNpcStateMachineComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
if (bBoundToDialogue)
{
if (UDialogueSubsystem* Subsystem = CachedDialogueSubsystem.Get())
{
Subsystem->OnDialogueSpeakerChanged.RemoveDynamic(this, &UNpcStateMachineComponent::OnDialogueSpeakerChanged);
}
bBoundToDialogue = false;
}
Super::EndPlay(EndPlayReason);
}
void UNpcStateMachineComponent::EnsureInitialised()
{
if (bInitialised)
{
return;
}
bInitialised = true;
// Resolved here rather than in BeginPlay because component BeginPlay runs in creation order and
// the schedule component is created before this one. Its very first act is to apply the frozen
// state, which for an NPC in range means telling this machine what to do, so the first
// instruction arrives before a BeginPlay of this component's own. Everything it needs is
// therefore resolved the first time it is asked for, whichever of the two comes first.
AActor* Owner = GetOwner();
ScheduleComponent = Owner != nullptr ? Owner->FindComponentByClass<UNpcScheduleComponent>() : nullptr;
Movement = Owner != nullptr ? Owner->FindComponentByClass<UExtendedPawnMovement>() : nullptr;
if (UDialogueSubsystem* Subsystem = UDialogueSubsystem::Get(this))
{
CachedDialogueSubsystem = Subsystem;
Subsystem->OnDialogueSpeakerChanged.AddDynamic(this, &UNpcStateMachineComponent::OnDialogueSpeakerChanged);
bBoundToDialogue = true;
}
// Nothing else starts this machine. An NPC with no schedule component is never told about an
// assignment, so it is left standing where it was placed rather than being guessed at.
}
void UNpcStateMachineComponent::OnNpcFrozen()
{
EnsureInitialised();
bFrozen = true;
if (UExtendedPawnMovement* WalkMovement = Movement)
{
// Brought to a stop here as well as by the schedule component, because this is the last
// moment anything is running that could have given the pawn a velocity to coast on.
WalkMovement->StopMoving();
}
// Inactive rather than a pause. An NPC the player cannot see has no behaviour of its own, and
// its tick going off is most of what makes a town's worth of them affordable.
EnterState(ENpcState::Inactive);
}
void UNpcStateMachineComponent::OnNpcUnfrozen(bool bAssignmentChanged)
{
EnsureInitialised();
bFrozen = false;
// Asked before anything else, because a conversation outlives the player walking in and out of
// range, and an NPC that is being talked to must not be teleported away from the person looking
// at it. Held here means Doing, not Moving: it stands where it is, and the walk is picked up when
// the conversation ends.
UpdateDialogueHold();
// An activity with nowhere to be, an idle or a stand with nothing to stand at, is done wherever
// the NPC already is, and whether the assignment changed does not matter to something like that.
// So this is the answer unless there is both a place to be and nobody watching.
ENpcState UnfrozenState = ENpcState::Doing;
FTransform Anchor;
if (!bHoldingForDialogue && ResolveAnchorTransform(Anchor) && !bAssignmentChanged)
{
// Same assignment it went away with, so the NPC should already have been standing there and
// the only reason it is not is that it was frozen before it arrived. Nobody has seen it since,
// which is the whole reason it is allowed to be put in place rather than walk, and that is
// what stops the street the player has just walked into from filling up with NPCs marching
// home.
TeleportToAnchor(Anchor);
}
else if (!bHoldingForDialogue && bAssignmentChanged)
{
// The clock moved on while the player was elsewhere, so this is a new place to be. It walks,
// because the player is here to see it walk, and nobody watched it leave the old one.
UnfrozenState = ENpcState::Moving;
}
EnterState(UnfrozenState);
// Acted on here rather than left to the next tick of the interval, because this is the moment the
// NPC appears in front of the player: a fifth of a second of standing still is a fifth of a second
// of an NPC that looks like it has just been switched on rather than one that lives here.
TickCurrentState(0.f);
}
void UNpcStateMachineComponent::OnScheduleActionChanged()
{
EnsureInitialised();
if (bFrozen)
{
// The schedule deliberately does not push anything at a frozen NPC, and the unfreeze that
// follows re-reads the clock and decides everything again from scratch.
return;
}
// The player is somewhere nearby, since an NPC only stays unfrozen while they are, so a new
// assignment is walked to and never teleported into. This is the one path where the move is meant
// to be seen.
EnterState(HasAnchor() ? ENpcState::Moving : ENpcState::Doing);
// Started now rather than on the next tick of the interval, so that the walk begins in the frame
// the clock changed rather than a fifth of a second into it.
TickCurrentState(0.f);
}
void UNpcStateMachineComponent::RequestReevaluate()
{
EnsureInitialised();
if (bFrozen)
{
return;
}
// A fresh look rather than a walk where it already stands: what the blueprint is really saying is
// that it has finished with whatever it was doing, and the answer to "what now" is the same one a
// new assignment gets.
EnterState(HasAnchor() ? ENpcState::Moving : ENpcState::Doing);
// Acted on immediately, so that a blueprint ending its activity is not the thing that introduces a
// delay before the NPC does anything about it.
TickCurrentState(0.f);
}
void UNpcStateMachineComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
EnsureInitialised();
TickCurrentState(DeltaTime);
}
void UNpcStateMachineComponent::TickCurrentState(float DeltaTime)
{
// Re-read every tick as well as from the delegate, because a dialogue can be ended by something
// that never touches this NPC's dialogue component, and being wrong about this for a fraction of
// a second is an NPC walking off in the middle of somebody's sentence.
UpdateDialogueHold();
if (bHoldingForDialogue)
{
// Held where it stands. Worth noticing that this is not a state: the NPC is still in the
// middle of whatever it was doing, and nothing here teleports, because the player is looking
// straight at it. The walk picks itself up from where it stopped once the talking is over.
StopWalking();
return;
}
switch (State)
{
case ENpcState::Moving:
TickMoving();
break;
case ENpcState::Doing:
TickDoing(DeltaTime);
break;
case ENpcState::Inactive:
default:
// Unreachable while the tick is off for Inactive, but there is nothing to do either way.
break;
}
}
void UNpcStateMachineComponent::EnterState(ENpcState NewState)
{
if (State != NewState)
{
const ENpcState Previous = State;
State = NewState;
// Moving and Doing both have something to look at every tick, and Inactive is precisely the
// state in which an NPC has nothing to do, so its tick goes off.
SetComponentTickEnabled(NewState != ENpcState::Inactive);
OnStateChanged.Broadcast(NewState, Previous);
}
// Skipped while frozen, because going Inactive there is about distance and nothing to do with
// the activity: telling a blueprint that an activity ended only for the same NPC to be unfrozen
// onto the very same one would cost it a teardown and a rebuild, out of sight both times.
if (!bFrozen)
{
RefreshActivityNotification();
}
}
void UNpcStateMachineComponent::TickMoving()
{
UExtendedPawnMovement* WalkMovement = Movement;
if (WalkMovement == nullptr)
{
// Nothing can walk this pawn, so it is already wherever it is going to be. The activity still
// happens, it just happens here.
EnterState(ENpcState::Doing);
return;
}
FVector Destination;
if (!ResolveDestination(Destination))
{
// Nowhere to go after all, which is what a walk to an anchor whose cell streams out mid walk
// looks like when the action carried no remembered transform behind it.
WalkMovement->StopMoving();
EnterState(ENpcState::Doing);
return;
}
// Armed every tick rather than once on the way in, because the place being walked to can change
// without the assignment changing: a bed pushed along the floor while an NPC is on its way to it,
// or the same bed coming back at the end of a cell that had streamed out.
WalkMovement->StartMoveToLocation(Destination, MoveSpeed, AcceptanceRadius);
if (WalkMovement->HasReachedMoveTarget())
{
WalkMovement->StopMoving();
EnterState(ENpcState::Doing);
}
}
void UNpcStateMachineComponent::TickDoing(float DeltaTime)
{
// The walk is not trusted to have ended by itself: a destination left behind by a state that was
// interrupted would keep this NPC walking away from where it is meant to be doing something, and
// being told to stop twice costs nothing.
StopWalking();
// Everything that is left to do once an NPC has arrived. Done per tick rather than once on the
// way in so that it also holds for an anchor that is moved afterwards, and for an NPC that was
// put on an anchor already facing the wrong way.
FaceAnchor(DeltaTime);
}
void UNpcStateMachineComponent::FaceAnchor(float DeltaTime)
{
AActor* Owner = GetOwner();
if (Owner == nullptr || AnchorTurnSpeed <= 0.f)
{
return;
}
FTransform Anchor;
if (!ResolveAnchorTransform(Anchor))
{
return;
}
const FRotator Current = Owner->GetActorRotation();
const float DesiredYaw = Anchor.Rotator().Yaw + AnchorYawOffset;
const float ToTurn = FMath::FindDeltaAngleDegrees(Current.Yaw, DesiredYaw);
if (FMath::Abs(ToTurn) <= FacingTolerance)
{
return;
}
const float Step = FMath::Clamp(ToTurn, -AnchorTurnSpeed * DeltaTime, AnchorTurnSpeed * DeltaTime);
if (FMath::IsNearlyZero(Step))
{
// Asked to turn with no time to do it in, which is what the callers that act immediately pass.
// Nothing to do rather than a needless transform update.
return;
}
// Upright on purpose: an anchor's pitch and roll describe the thing itself, and a pawn that lay
// back along the bed it is standing at would be in the floor.
Owner->SetActorRotation(FRotator(0.f, Current.Yaw + Step, 0.f));
}
bool UNpcStateMachineComponent::ResolveAnchorTransform(FTransform& OutTransform) const
{
const FResolvedNpcAction* Action = GetScheduleAction();
return Action != nullptr && Action->GetAnchorTransform(OutTransform);
}
bool UNpcStateMachineComponent::ResolveDestination(FVector& OutLocation) const
{
FTransform Anchor;
if (!ResolveAnchorTransform(Anchor))
{
return false;
}
// Asked of the movement rather than taken from the anchor as it stands, because an anchor says
// where a prop is and a destination says where a pawn is, and those are not the same height: a
// machine with no movement to ask is left with the anchor as it was.
const FVector AnchorSpot = Anchor.GetLocation() + AnchorLocationOffset;
OutLocation = Movement != nullptr ? Movement->GetStandingLocationFor(AnchorSpot) : AnchorSpot;
return true;
}
void UNpcStateMachineComponent::TeleportToAnchor(const FTransform& AnchorTransform)
{
AActor* Owner = GetOwner();
if (Owner == nullptr)
{
return;
}
// The spot a pawn would stand on at this anchor, which is the same answer the walk asks for above,
// so that being put on an anchor and walking to one come to the same place.
UExtendedPawnMovement* WalkMovement = Movement;
const FVector AnchorSpot = AnchorTransform.GetLocation() + AnchorLocationOffset;
const FVector Destination = WalkMovement != nullptr ? WalkMovement->GetStandingLocationFor(AnchorSpot) : AnchorSpot;
const FRotator Facing(0.f, AnchorTransform.Rotator().Yaw + AnchorYawOffset, 0.f);
if (WalkMovement != nullptr)
{
// Asked of the movement, with the machine's own speed and radius passed straight through, so
// that "close enough to count as arrived" is one answer shared by walking and this rather
// than two numbers that can drift apart. Whether the pawn is already there is all this is
// after, so whatever the question leaves the controller doing is taken back off it at once.
WalkMovement->StartMoveToLocation(Destination, MoveSpeed, AcceptanceRadius);
const bool bAlreadyThere = WalkMovement->HasReachedMoveTarget();
WalkMovement->StopMoving();
if (bAlreadyThere)
{
// Near enough that being moved would not be worth seeing, and it could be seen.
return;
}
}
// Teleport physics, so an NPC put back onto the spot it should have been standing on cannot be
// shoved off it by the very move that puts it there, which is why the save load uses it too.
Owner->SetActorLocationAndRotation(Destination, Facing, false, nullptr, ETeleportType::TeleportPhysics);
}
void UNpcStateMachineComponent::RefreshActivityNotification()
{
const FResolvedNpcAction* Action = GetScheduleAction();
const bool bDoing = State == ENpcState::Doing && Action != nullptr;
if (!bDoing)
{
if (bHasNotifiedActivity)
{
bHasNotifiedActivity = false;
OnActivityLeft(NotifiedActivity);
}
return;
}
if (bHasNotifiedActivity && NotifiedActivity == Action->Activity)
{
return;
}
if (bHasNotifiedActivity)
{
// Straight out of one activity and into another, an NPC that goes from sitting to working at
// the same bench. The blueprint hears that the first is over before it hears about the second,
// which is the same order it would see if the NPC had walked somewhere in between.
OnActivityLeft(NotifiedActivity);
}
NotifiedActivity = Action->Activity;
bHasNotifiedActivity = true;
// The anchor goes with it so that whatever is being drawn knows what it is being drawn against:
// which bed, which bench. Null is a perfectly good answer and means the activity happens wherever
// the NPC happens to be standing.
OnActivityEntered(NotifiedActivity, Action->Anchor.Get());
}
const FResolvedNpcAction* UNpcStateMachineComponent::GetScheduleAction() const
{
const UNpcScheduleComponent* Schedule = ScheduleComponent;
return (Schedule != nullptr && Schedule->HasAction()) ? &Schedule->GetCurrentAction() : nullptr;
}
bool UNpcStateMachineComponent::HasAnchor() const
{
FTransform Anchor;
return ResolveAnchorTransform(Anchor);
}
bool UNpcStateMachineComponent::GetCurrentAction(FResolvedNpcAction& OutAction) const
{
const FResolvedNpcAction* Action = GetScheduleAction();
if (Action == nullptr)
{
return false;
}
OutAction = *Action;
return true;
}
AActor* UNpcStateMachineComponent::GetCurrentAnchor() const
{
const FResolvedNpcAction* Action = GetScheduleAction();
return Action != nullptr ? Action->Anchor.Get() : nullptr;
}
void UNpcStateMachineComponent::StopWalking()
{
if (UExtendedPawnMovement* WalkMovement = Movement)
{
if (WalkMovement->IsMovingToTarget())
{
WalkMovement->StopMoving();
}
}
}
bool UNpcStateMachineComponent::IsDialogueActiveWithOwner() const
{
const UDialogueSubsystem* Subsystem = CachedDialogueSubsystem.Get();
return Subsystem != nullptr && Subsystem->IsSpeakingTo(GetOwner());
}
void UNpcStateMachineComponent::UpdateDialogueHold()
{
// Nothing more than recording it. A held NPC is not in a state of its own: it is in the middle of
// whatever it was doing, and the walk picks itself up from where it stopped on the first tick
// after the flag clears, because Moving re-arms the move every tick anyway.
bHoldingForDialogue = IsDialogueActiveWithOwner();
}
void UNpcStateMachineComponent::OnDialogueSpeakerChanged(AActor* Speaker)
{
UpdateDialogueHold();
if (bHoldingForDialogue)
{
// Stopped here rather than on the next tick, so that a conversation opening does not let the
// NPC take another step away from the person who started it.
StopWalking();
}
}

View File

@@ -0,0 +1,240 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "ProjectEleri/Ai/NpcScheduleAsset.h"
#include "NpcStateMachineComponent.generated.h"
class UDialogueSubsystem;
class UExtendedPawnMovement;
class UNpcScheduleComponent;
/** What an NPC is doing. There are only three answers and they are exhaustive, because an NPC has no
* behaviour beyond its schedule: it is either held back with nothing to do, on its way somewhere, or
* at the place its schedule points it at. */
UENUM(BlueprintType)
enum class ENpcState : uint8
{
/** Nothing is being driven: the NPC is out of range and frozen, or the schedule has no answer for
* the current minute. Its tick is off, which is most of what makes a town of these affordable. */
Inactive UMETA(DisplayName = "Inactive"),
/** On its way to the anchor of the action it was assigned. */
Moving UMETA(DisplayName = "Moving"),
/** At the anchor, doing the action there. This is where an NPC spends almost all of its day. */
Doing UMETA(DisplayName = "Doing")
};
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnNpcStateChanged, ENpcState, NewState, ENpcState, PreviousState);
/**
* The behaviour of one scheduled NPC, in C++ rather than in a StateTree.
*
* The schedule component owns the when: it reads the clock, works out what this NPC should be doing,
* freezes it while the player is nowhere near, and tells this component what it decided. This
* component owns the how: it walks the pawn to the anchor the assignment names, or puts it there
* outright when nobody was watching, and it holds still while the player is talking to it.
*
* It is deliberately not a state tree. A tree earns its keep when behaviour has branches, and this
* has none: every activity in the schedule does the same two things, which is going somewhere and
* then being there. What the tree was paying for was a tick, a context, a set of tasks and an asset
* to keep in sync with the enum, and what replaces all of that is the switch in TickCurrentState.
*
* The visible half of an activity, a sleeping sprite or a work pose, stays in the blueprint: this
* fires OnActivityEntered and OnActivityLeft and leaves the appearance to whoever is drawing the
* NPC. Likewise the pawn is walked through UExtendedPawnMovement, the same component the StateTree
* tasks used, which passes the destination on to the pawn's AI controller: the route across the
* navmesh and the walking of it are the engine's default path following, so nothing about how an NPC
* moves has changed.
*/
UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent), BlueprintType, Blueprintable)
class PROJECTELERI_API UNpcStateMachineComponent : public UActorComponent
{
GENERATED_BODY()
public:
UNpcStateMachineComponent();
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
/** Fired on every change of state, which is what a blueprint wants to hook for anything that has
* to follow the NPC's behaviour as a whole rather than one activity. */
UPROPERTY(BlueprintAssignable, Category = "Npc|State")
FOnNpcStateChanged OnStateChanged;
/** How fast this NPC walks, in units per second. Zero means as fast as its movement component
* goes, which is the answer whenever nobody cares how fast an NPC walks. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Npc|Movement", meta = (ClampMin = "0.0", UIMin = "0.0"))
float MoveSpeed = 0.f;
/** How close to the anchor counts as arrived. Negative uses the movement component's own default,
* so the walk and the teleport cannot disagree about when an NPC is there. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Npc|Movement", meta = (ClampMin = "-1.0", UIMin = "-1.0"))
float AcceptanceRadius = -1.f;
/** Nudges the spot an NPC stands on, for an anchor authored at the head of a bed rather than at
* the middle of it. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Npc|Anchor")
FVector AnchorLocationOffset = FVector::ZeroVector;
/** Added to the anchor's own yaw, which is how an NPC is made to face away from a chair it is
* sitting in or into a workbench it is standing at. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Npc|Anchor")
float AnchorYawOffset = 0.f;
/** How fast the NPC turns to face its anchor once it is doing something there. Zero leaves its
* rotation alone, which is what a project that turns NPCs in its own blueprint wants. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Npc|Anchor", meta = (ClampMin = "0.0", UIMin = "0.0"))
float AnchorTurnSpeed = 180.f;
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Npc|State")
ENpcState GetState() const { return State; }
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Npc|State")
bool IsInactive() const { return State == ENpcState::Inactive; }
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Npc|State")
bool IsMoving() const { return State == ENpcState::Moving; }
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Npc|State")
bool IsDoing() const { return State == ENpcState::Doing; }
/** True while the player is talking to this NPC. It is held exactly where it stands for as long
* as that lasts, whichever state it was in, and carries on afterwards. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Npc|State")
bool IsHoldingForDialogue() const { return bHoldingForDialogue; }
/** True while the schedule has this NPC frozen, i.e. while it is far enough from the player to be
* out of sight and out of mind. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Npc|State")
bool IsFrozen() const { return bFrozen; }
/** What the NPC has been told to do, as the schedule resolved it. False when the schedule has not
* answered yet, which is the case for an NPC with no tag or one that is still frozen. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Npc|State")
bool GetCurrentAction(FResolvedNpcAction& OutAction) const;
/** The actor the current action happens at, or null when it has no anchor or that anchor is in a
* cell the player has walked away from. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Npc|State")
AActor* GetCurrentAnchor() const;
/** Called by the schedule component when this NPC is put away. */
UFUNCTION(BlueprintCallable, Category = "Npc|State")
void OnNpcFrozen();
/** Called by the schedule component when this NPC comes back into range.
*
* AssignmentChanged says whether the schedule has handed out something different from what the
* NPC was frozen on, and it is passed rather than worked out here because the schedule component
* is the only thing that knows: it re-reads the clock on unfreeze and compares the two. That
* answer is what decides between walking and being put where it should already be, on the
* grounds that an NPC nobody has looked at since it stopped can be moved without being seen. */
UFUNCTION(BlueprintCallable, Category = "Npc|State")
void OnNpcUnfrozen(bool bAssignmentChanged);
/** Called by the schedule component when the clock moves this NPC onto a different action while
* it is in range, which is to say while the player may well be watching. */
UFUNCTION(BlueprintCallable, Category = "Npc|State")
void OnScheduleActionChanged();
/** Asks the machine to work out what it should be doing again. For a blueprint whose activity has
* a length of its own and wants the NPC sent on its way once that is over. */
UFUNCTION(BlueprintCallable, Category = "Npc|State")
void RequestReevaluate();
/** The visible half of an activity belongs to the blueprint: this fires once on the way into one,
* with the anchor, so whatever is being drawn can know what it is being drawn against. */
UFUNCTION(BlueprintImplementableEvent, Category = "Npc|State")
void OnActivityEntered(ENpcActivity Activity, AActor* Anchor);
/** The way out of an activity, fired once and whichever way it was left, so that a blueprint can
* undo what it did on entering one without tracking the state itself. */
UFUNCTION(BlueprintImplementableEvent, Category = "Npc|State")
void OnActivityLeft(ENpcActivity Activity);
protected:
/** Everything this component borrows from its owner and its world, resolved on first use. */
void EnsureInitialised();
/** Moves into NewState, fires the state change when that is one, and settles what the blueprint
* has been told about the current activity either way. */
void EnterState(ENpcState NewState);
/** What the state asks for right now, and what the tick runs. Also called by hand whenever a
* driver has just decided something, so that a walk starts in the frame it was decided on rather
* than up to a fifth of a second later. */
void TickCurrentState(float DeltaTime);
void TickMoving();
void TickDoing(float DeltaTime);
/** Where this NPC is walking to: the anchor of its current action, or false when that action has
* no anchor or the anchor cannot be placed at all. */
bool ResolveDestination(FVector& OutLocation) const;
bool ResolveAnchorTransform(FTransform& OutTransform) const;
/** Puts the NPC on the anchor outright, unless it is already close enough that walking there and
* being moved there are indistinguishable. Only ever called for an NPC nobody is looking at. */
void TeleportToAnchor(const FTransform& AnchorTransform);
/** Turns the NPC towards its anchor, which is all that is left to do once it has arrived. */
void FaceAnchor(float DeltaTime);
/** Takes back whatever walk is in progress, for a state that is no longer going anywhere. */
void StopWalking();
/** Tells the blueprint about a change of activity and only about a change, so that a state which
* passes through Doing more than once for one action fires one of each event and no more. */
void RefreshActivityNotification();
const FResolvedNpcAction* GetScheduleAction() const;
bool HasAnchor() const;
bool IsDialogueActiveWithOwner() const;
/** Re-reads whether the player is talking to this NPC, and continues its day when that stops. */
void UpdateDialogueHold();
UFUNCTION()
void OnDialogueSpeakerChanged(AActor* Speaker);
/** The schedule driving this NPC, null when it has none. */
UPROPERTY(Transient)
TObjectPtr<UNpcScheduleComponent> ScheduleComponent;
/** The movement that actually moves the pawn, null when the owner has none. */
UPROPERTY(Transient)
TObjectPtr<UExtendedPawnMovement> Movement;
UPROPERTY(Transient)
TWeakObjectPtr<UDialogueSubsystem> CachedDialogueSubsystem;
/** The activity the blueprint has been told about, and whether it has been told about one at all,
* so that entering one and leaving one are each announced exactly once. */
ENpcActivity NotifiedActivity = ENpcActivity::Idle;
bool bHasNotifiedActivity = false;
/** False until the schedule has frozen or unfrozen this NPC at least once. */
bool bFrozen = false;
/** True while the player is talking to this NPC. */
bool bHoldingForDialogue = false;
bool bBoundToDialogue = false;
bool bInitialised = false;
private:
ENpcState State = ENpcState::Inactive;
};

View File

@@ -13,6 +13,37 @@
#include "ProjectEleri/Ai/NpcScheduleComponent.h"
#include "ProjectEleri/GameObjects/BaseCharacter.h"
namespace
{
/** The record a save holds for an actor, looked up by its id and then by its name.
*
* The id is the real key, and the name is the fallback for a save written before the ids were
* stable: such a save filed every one of these actors under an id that was rolled again on the next
* launch, so that id can never match again, and the actor name is the only thing it recorded. Set
* onto a record whose id is therefore a dead end. No caller is left worse off by it either, because
* the load that follows checks the name it has just matched against the actor's own name.
*
* Walked rather than keyed by name in a map of its own, because this is a miss path: on a save
* written since the ids became stable the id lookup answers and the walk never happens. */
const FActorSaveData* FindActorSaveData(const TMap<FGuid, FActorSaveData>& ActorSaveData, const UObject& Actor, const FGuid& PersistentId)
{
if (const FActorSaveData* FoundData = ActorSaveData.Find(PersistentId))
{
return FoundData;
}
for (const TPair<FGuid, FActorSaveData>& Entry : ActorSaveData)
{
if (Entry.Value.ActorName == Actor.GetFName())
{
return &Entry.Value;
}
}
return nullptr;
}
}
AEleriGameState* AEleriGameState::Get(const UObject* WorldContext)
{
if (WorldContext && WorldContext->GetWorld() && WorldContext->GetWorld()->GetGameState())
@@ -157,10 +188,13 @@ void AEleriGameState::PropagateSaveData(UEleriSaveGame* InSaveData)
{
continue;
}
if (auto ActorSaveData = SaveData.ActorSaveData.Find(
Cast<IActorPersistentIdInterface>(Actor)->GetPersistentId()))
if (const FActorSaveData* ActorSaveData = FindActorSaveData(
SaveData.ActorSaveData, *Actor, Cast<IActorPersistentIdInterface>(Actor)->GetPersistentId()))
{
Cast<ISaveableObjectInterface>(Actor)->RequestLoad(*ActorSaveData);
// Copied, because RequestLoad takes the record by mutable reference while the one inside the
// save is only ever read from here.
FActorSaveData RecordToLoad = *ActorSaveData;
Cast<ISaveableObjectInterface>(Actor)->RequestLoad(RecordToLoad);
}
}
@@ -289,26 +323,56 @@ const FSaveData& AEleriGameState::PrepareSaveData()
void AEleriGameState::CacheActorData(AEleriBaseActor* EleriActor)
{
if (!IsValid(EleriActor)) return;
if (auto FoundActorData = SaveData.ActorSaveData.Find(EleriActor->GetPersistentId()))
const FGuid PersistentId = EleriActor->GetPersistentId();
const FActorSaveData ActorData = Cast<ISaveableObjectInterface>(EleriActor)->RequestSave();
if (FActorSaveData* FoundActorData = SaveData.ActorSaveData.Find(PersistentId))
{
*FoundActorData = Cast<ISaveableObjectInterface>(EleriActor)->RequestSave();
*FoundActorData = ActorData;
return;
}
else
// The id lookup above is the whole of the normal path. What the walk below is for is a save written
// before the ids were stable: it filed this actor under an id that was rolled again on the next
// launch, and simply adding the actor under today's id on top of that would leave both records in
// the save, one of them something nothing will ever read again. The stale one is moved over instead.
// A name is only stable for as long as the level is, so this is the fallback and never the first
// choice.
FGuid StaleKey;
for (const TPair<FGuid, FActorSaveData>& Entry : SaveData.ActorSaveData)
{
SaveData.ActorSaveData.Add(Cast<IActorPersistentIdInterface>(EleriActor)->GetPersistentId(),
Cast<ISaveableObjectInterface>(EleriActor)->RequestSave());
if (Entry.Value.ActorName == ActorData.ActorName)
{
StaleKey = Entry.Key;
break;
}
}
if (StaleKey.IsValid())
{
SaveData.ActorSaveData.Remove(StaleKey);
}
SaveData.ActorSaveData.Add(PersistentId, ActorData);
}
bool AEleriGameState::RequestActorSaveData(const AEleriBaseActor* EleriActor, FActorSaveData& OutData) const
{
if (!IsValid(EleriActor)) return false;
if (auto FoundActorData = SaveData.ActorSaveData.Find(EleriActor->GetPersistentId()))
// Looked up by id and then by name, see FindActorSaveData. The same two passes the promote loop in
// PropagateSaveData uses, so an actor is restored however the load reaches it: through that loop, or
// through its own BeginPlay when it enters the world after the load has already been applied.
const FActorSaveData* FoundActorData =
FindActorSaveData(SaveData.ActorSaveData, *EleriActor, EleriActor->GetPersistentId());
if (FoundActorData != nullptr)
{
OutData = *FoundActorData;
return true;
}
return false;
}

View File

@@ -0,0 +1,98 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "Interface/ActorPersistentIdInterface.h"
#include "Kismet/GameplayStatics.h"
namespace EleriPersistentId
{
void Ensure(FGuid& Id, const UObject& Owner)
{
// A template is the one object whose id must stay invalid, and the reason is delta serialization:
// an instance is only written to disk for the properties it differs from its archetype in, so an
// archetype that carries an id is an archetype every instance of that class can match, and an id
// that nothing ever writes. This also clears anything a blueprint asset saved before this helper
// existed, which would otherwise be an id all of its instances share.
if (Owner.IsTemplate())
{
Id.Invalidate();
return;
}
if (Id.IsValid()) return;
Id = FGuid::NewGuid();
}
void Regenerate(FGuid& Id, const UObject& Owner)
{
// A template holds no id by definition, so regenerating one is a call that came from the wrong
// place rather than an edit, and it leaves the template invalid rather than giving it a value.
if (Owner.IsTemplate())
{
Id.Invalidate();
return;
}
Id = FGuid::NewGuid();
}
int32 CountOtherClaimants(const UObject& Owner, const FGuid& Id)
{
// An invalid id is claimed by nobody by definition, including by every other actor that has no id
// yet, so counting them would turn the healthy state into a report full of collisions.
if (!Id.IsValid()) return 0;
const UWorld* World = Owner.GetWorld();
if (World == nullptr) return -1;
TArray<AActor*> Claimants;
UGameplayStatics::GetAllActorsWithInterface(World, UActorPersistentIdInterface::StaticClass(), Claimants);
int32 Count = 0;
for (const AActor* Claimant : Claimants)
{
if (Claimant == &Owner) continue;
const IActorPersistentIdInterface* Identified = Cast<IActorPersistentIdInterface>(Claimant);
if (Identified != nullptr && Identified->GetPersistentId() == Id)
{
++Count;
}
}
return Count;
}
EDataValidationResult ValidateId(const UObject& Owner, const FGuid& Id, FDataValidationContext& Context, EDataValidationResult InResult)
{
// A template is the one object that is meant to have no id, see Ensure, and compiling a blueprint
// validates its class default object. Reporting one there would fail the compile of every blueprint
// of every class that implements this interface, and it would be a report about a state that is not
// a fault: nothing points at a template, so there is no reference for the id to be checked against.
if (Owner.IsTemplate()) return InResult;
if (!Id.IsValid())
{
Context.AddError(NSLOCTEXT("EleriPersistentId", "IdMissing",
"This actor has no PersistentId, so nothing can point at it and a save would file it under the empty id. Reopening the level gives it one."));
return EDataValidationResult::Invalid;
}
// A worldless actor has nothing to compare against, so it is left at whatever the base object
// decided rather than being reported as clean or as a duplicate.
const int32 Claimants = CountOtherClaimants(Owner, Id);
if (Claimants > 0)
{
Context.AddError(FText::Format(
NSLOCTEXT("EleriPersistentId", "IdDuplicated",
"This actor shares its PersistentId with {0} other actor(s) in the level. A reference that stores the id resolves to whichever of them is found first, so one of them has to be given an id of its own from the Regenerate Persistent Id button."),
FText::AsNumber(Claimants)));
return EDataValidationResult::Invalid;
}
return InResult;
}
}

View File

@@ -8,7 +8,8 @@ public class ProjectEleri : ModuleRules
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[] {
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
@@ -26,6 +27,8 @@ public class ProjectEleri : ModuleRules
"Json",
"JsonUtilities",
"GameplayTasks",
"AIModule",
"NavigationSystem",
"StateTreeModule",
"GameplayStateTreeModule",
"DirectiveUtilitiesRuntime",
@@ -33,7 +36,8 @@ public class ProjectEleri : ModuleRules
"InteractionSystem"
});
PrivateDependencyModuleNames.AddRange(new string[] { "OnlineSubsystem", "OnlineSubsystemUtils", "Landscape", "LTween" });
PrivateDependencyModuleNames.AddRange(new string[]
{ "OnlineSubsystem", "OnlineSubsystemUtils", "Landscape", "LTween" });
// Uncomment if you are using Slate UI
// PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
@@ -43,4 +47,4 @@ public class ProjectEleri : ModuleRules
// To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true
}
}
}

View File

@@ -4,6 +4,7 @@
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "Misc/DataValidation.h"
#include "ActorPersistentIdInterface.generated.h"
// This class does not need to be modified.
@@ -13,7 +14,12 @@ class UActorPersistentIdInterface : public UInterface {
};
/**
* Identity for an actor something else has to keep pointing at.
*
* It lives in the actor rather than in whatever points at it, so an NPC schedule can store an id
* instead of a raw actor pointer and still find its bed after that bed has been streamed out and back
* in, after a re-cook, or after the level has been re-saved. Implementing this interface is the whole
* of the "can this actor be an anchor" rule, see FNpcScheduleActionCustomization.
*/
class PROJECTELERI_API IActorPersistentIdInterface {
GENERATED_BODY()
@@ -21,5 +27,47 @@ class PROJECTELERI_API IActorPersistentIdInterface {
// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
/** The id of this actor. Invalid on a template, valid on every real instance, see
* EleriPersistentId::Ensure for why that split is what keeps the id from being regenerated. */
virtual FGuid GetPersistentId() const { return FGuid(0, 0, 0, 0); }
};
/** Adopts an id. Used to put back an id that was saved away when the actor it belonged to is
* spawned in again, since a spawned actor is never loaded and so never carries one of its own.
* A class with nowhere to put the id ignores the call, which is why the default does nothing. */
virtual void SetPersistentId(const FGuid& InPersistentId) {}
};
/**
* Production of the ids above, shared by every implementer so the rule is written down once.
*
* The id is deliberately NOT generated in the constructor, and that is the whole point. Delta
* serialization only writes the properties of an instance that differ from its archetype, and the class
* default object is built by the same constructor, so an id baked in there is a random value that an
* instance can legitimately end up equal to, in which case its id is silently never written to disk.
*
* Leaving the template invalid fixes that from both ends: every instance is guaranteed to differ from
* it and so to be serialized, and a placed actor whose level data predates the property is handed an id
* on load instead of inheriting whatever the constructor rolled.
*/
namespace EleriPersistentId
{
/** Gives Owner an id if it has none. Keeps templates invalid and leaves an existing id alone, so it
* is safe to call from every construction and load path. */
PROJECTELERI_API void Ensure(FGuid& Id, const UObject& Owner);
/** Hands Owner a new id whether or not it had one, for a deliberate regeneration. */
PROJECTELERI_API void Regenerate(FGuid& Id, const UObject& Owner);
/** How many other actors in Owner's world already claim Id, and -1 when Owner has no world to look
* in. The editor helpers use this so that a duplicated id is reported while it can still be fixed,
* instead of being discovered as a schedule that resolves to the wrong actor. */
PROJECTELERI_API int32 CountOtherClaimants(const UObject& Owner, const FGuid& Id);
/** The verdict for Id folded into InResult, plus the issue appended to Context when there is one.
* Shared by the implementers so that all of them say the same thing about the same fault, and so
* that the wording of a level validation only exists once.
*
* A template is passed through untouched and reported no cleaner than the base object made it,
* because a template holds no id by design and blueprint compilation validates the default object. */
PROJECTELERI_API EDataValidationResult ValidateId(const UObject& Owner, const FGuid& Id, FDataValidationContext& Context, EDataValidationResult InResult);
}

View File

@@ -54,14 +54,11 @@ struct FNpcScheduleSaveData
UPROPERTY()
uint8 Activity = 0;
/* Ids rather than actors, because the bed or chair an NPC walks to may well be streamed out */
/* An id rather than an actor, 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 */
/* False when the NPC had not been given an assignment yet, in which case the id above means nothing */
UPROPERTY()
bool bHasAction = false;
};

View File

@@ -5,19 +5,52 @@
#include "Interface/RemovableActorInterface.h"
#include "Kismet/GameplayStatics.h"
#include "../../Public/EleriSaveGame.h"
#include "../../Public/Interface/ActorPersistentIdInterface.h"
#include "../GameObjects/RemovableStaticMeshActor.h"
#include "../System/MainBlueprintFunctionLibrary.h"
namespace
{
/** An actor's id, or an invalid one when it has none to give. Spelled out rather than reached
* through a cast at each call site, because every call site below holds an AActor*. */
FGuid GetActorPersistentId(const AActor& Actor)
{
const IActorPersistentIdInterface* Identified = Cast<IActorPersistentIdInterface>(&Actor);
return Identified != nullptr ? Identified->GetPersistentId() : FGuid();
}
/** True when an actor is the one a record was written about.
*
* The id is what a record is really about, and it is the only key that still means anything once
* the object has been spawned again in a later session: a spawned instance's name carries a number
* that depends on the order things were spawned in, and on how many of that class the level holds,
* so an author adding or removing one of them renumbers the rest. The name is checked as well,
* because it is the only key a record written before the id existed has, and because it also
* covers a level placed object whose data has not been re-saved since. */
bool IsRecordForActor(const FPlaceableActorInfo& Record, const AActor& Actor)
{
if (Record.ActorName == Actor.GetName())
{
return true;
}
if (!Record.PersistentId.IsValid())
{
return false;
}
return GetActorPersistentId(Actor) == Record.PersistentId;
}
}
int32 UObjectPersistenceSubsystem::ActorExistsInList(AActor* ActorToRemove)
{
if (!ActorToRemove) return -1;
for (int32 i = 0; i < PlacedActors.Num(); i++)
{
if (ActorToRemove->GetName() == PlacedActors[i].ActorName)
return i;
}
return -1;
// INDEX_NONE and the -1 this used to return are the same value, so the callers that compare
// against INDEX_NONE keep working whether or not they spell it out.
return PlacedActors.IndexOfByPredicate(
[ActorToRemove](const FPlaceableActorInfo& Record) { return IsRecordForActor(Record, *ActorToRemove); });
}
void UObjectPersistenceSubsystem::RemoveActorFromLevel(AActor* ActorToRemove)
@@ -34,7 +67,10 @@ void UObjectPersistenceSubsystem::RemoveActorFromLevel(AActor* ActorToRemove)
void UObjectPersistenceSubsystem::PlacebleActorMoved(AActor* ActorMoved, bool bNewVisiblity)
{
FPlaceableActorInfo* FoundActor = PlacedActors.FindByPredicate([ActorMoved](const FPlaceableActorInfo& ActorInfo) { return ActorInfo.ActorName == ActorMoved->GetName(); });
if (!ActorMoved) return;
FPlaceableActorInfo* FoundActor = PlacedActors.FindByPredicate(
[ActorMoved](const FPlaceableActorInfo& Record) { return IsRecordForActor(Record, *ActorMoved); });
if (FoundActor)
{
@@ -42,6 +78,11 @@ void UObjectPersistenceSubsystem::PlacebleActorMoved(AActor* ActorMoved, bool bN
{
FoundActor->Transform = ActorMoved->GetActorTransform();
FoundActor->bVisible = bNewVisiblity;
// The pair is refreshed as well, because a record matched through one of the two can still be
// carrying the other from an older session. Without this a record found by name keeps an
// invalid id forever, and the id is what the next session has to go on.
FoundActor->ActorName = ActorMoved->GetName();
FoundActor->PersistentId = GetActorPersistentId(*ActorMoved);
}
else
{
@@ -54,6 +95,7 @@ void UObjectPersistenceSubsystem::PlacebleActorMoved(AActor* ActorMoved, bool bN
FPlaceableActorInfo NewPlaceableInfo;
NewPlaceableInfo.ClassReferencePath = ActorMoved->GetClass();
NewPlaceableInfo.ActorName = ActorMoved->GetName();
NewPlaceableInfo.PersistentId = GetActorPersistentId(*ActorMoved);
NewPlaceableInfo.Transform = ActorMoved->GetTransform();
NewPlaceableInfo.bVisible = bNewVisiblity;
PlacedActors.Add(NewPlaceableInfo);
@@ -67,9 +109,20 @@ void UObjectPersistenceSubsystem::GatherAllActorsInWorld()
for (auto Actor : OutActors)
{
if (!Actor) continue;
// An actor that is already recorded is left alone rather than recorded a second time. Two records
// for one object is two records claiming one id, and the later of the two would put the object
// back where it was when it was gathered instead of where it is now.
if (PlacedActors.ContainsByPredicate([&Actor](const FPlaceableActorInfo& Record) { return IsRecordForActor(Record, *Actor); }))
{
continue;
}
FPlaceableActorInfo NewPlaceableInfo;
NewPlaceableInfo.ClassReferencePath = Actor->GetClass();
NewPlaceableInfo.ActorName = Actor->GetName();
NewPlaceableInfo.PersistentId = GetActorPersistentId(*Actor);
NewPlaceableInfo.Transform = Actor->GetTransform();
NewPlaceableInfo.bVisible = true;
PlacedActors.Add(NewPlaceableInfo);
@@ -94,18 +147,22 @@ void UObjectPersistenceSubsystem::LoadSavedData(const TArray<FPlaceableActorInfo
for (int32 i = 0; i < InPlacedActors.Num(); i++)
{
FString ActorName = InPlacedActors[i].ActorName;
AActor** FoundActor = OutActors.FindByPredicate([&ActorName](const AActor* Actor) { return ActorName == Actor->GetName(); });
const FPlaceableActorInfo& Record = InPlacedActors[i];
// Matched through IsRecordForActor, so an object that was spawned in an earlier session is
// recognised by its id even though the name it has this time round is a fresh one.
AActor** FoundActor = OutActors.FindByPredicate(
[&Record](const AActor* Actor) { return Actor != nullptr && IsRecordForActor(Record, *Actor); });
if (FoundActor && *FoundActor)
{
if (!(*FoundActor)->GetActorTransform().Equals(InPlacedActors[i].Transform, 0.000001f))
if (!(*FoundActor)->GetActorTransform().Equals(Record.Transform, 0.000001f))
{
AActor* ActorToModify = *FoundActor;
if (AStaticMeshActor* StaticMeshActor = Cast<AStaticMeshActor>(ActorToModify))
{
StaticMeshActor->SetMobility(EComponentMobility::Movable);
ActorToModify->SetActorTransform(InPlacedActors[i].Transform);
ActorToModify->SetActorTransform(Record.Transform);
IndexesOfFoundActors.Add(i);
StaticMeshActor->SetMobility(EComponentMobility::Static);
}
@@ -113,16 +170,27 @@ void UObjectPersistenceSubsystem::LoadSavedData(const TArray<FPlaceableActorInfo
}
else
{
UClass* ClassToLoad = InPlacedActors[i].ClassReferencePath.TryLoadClass<ARemovableStaticMeshActor>();
UClass* ClassToLoad = Record.ClassReferencePath.TryLoadClass<ARemovableStaticMeshActor>();
if (!ClassToLoad) continue;
FTransform PlaceableTransform = InPlacedActors[i].Transform;
FTransform PlaceableTransform = Record.Transform;
if (ARemovableStaticMeshActor* SpawnedActor = Cast<ARemovableStaticMeshActor>(UMainBlueprintFunctionLibrary::SpawnPlaceable(GetWorld(), ClassToLoad, PlaceableTransform, FVector::OneVector)))
{
SpawnedActor->Place(true);
}
// The saved id is adopted before the object is put down, never after, because putting it
// down is what writes its record. A record written with the id the spawn happened to roll
// would be a second record for an object that has been the same one all along, and the
// two would then be read back as two objects stacked on top of each other.
if (Record.PersistentId.IsValid())
{
SpawnedActor->SetPersistentId(Record.PersistentId);
}
PlacedActors.Add(InPlacedActors[i]);
SpawnedActor->Place(true);
// Place() is what records this object in PlacedActors, so nothing is added here: adding
// the saved record on top of it would leave two records for one id, and the stale one of
// the two would move the object back to where it stood when the save was written.
}
}
}

View File

@@ -19,6 +19,13 @@ struct FPlaceableActorInfo
UPROPERTY(BlueprintReadOnly)
FString ActorName;
/** The placed object's PersistentId, and the key its record is really about. The name is kept next
* to it because the name of a spawned instance carries a number that depends on the order things
* were spawned in, so it is not the same string in the next session, while the id is. A record
* written before this field existed leaves it invalid, and the name is what those fall back to. */
UPROPERTY(BlueprintReadOnly)
FGuid PersistentId;
UPROPERTY(BlueprintReadOnly)
FTransform Transform;

View File

@@ -2,9 +2,14 @@
#include "ScheduleSubsystem.h"
#include "TimeOfDayManager.h"
#include "ProjectEleri/GameObjects/EleriStandPoint.h"
#include "ProjectEleri/Settings/EleriGameSettings.h"
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
#include "Kismet/GameplayStatics.h"
#include "ProjectEleri/Ai/NpcScheduleComponent.h"
#include "ProjectEleri/NPC/NpcBaseCharacter.h"
void UScheduleSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
@@ -33,6 +38,10 @@ void UScheduleSubsystem::Initialize(FSubsystemCollectionBase& Collection)
*SchedulePair.Key.ToString());
}
}
GetWorld()->GetTimerManager().SetTimer(SchedulePositioningCheckTimerHandle, this, &UScheduleSubsystem::CheckFrozenNpcPositioning, 1.f, true, 2.f);
RebuildAnchorIndex();
}
UNpcScheduleAsset* UScheduleSubsystem::GetScheduleForNpcTag(FGameplayTag NpcTag) const
@@ -62,13 +71,16 @@ bool UScheduleSubsystem::ResolveAction(FGameplayTag NpcTag, int32 MinutesOfDay,
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();
// Where to stand rather than where the actor is, which for furniture is the same thing only by
// accident: see EleriStandPoint. Worked out once and used for the cache below as well, so that
// the place an NPC is sent while its anchor is loaded and the place it is sent once that anchor
// has streamed out are one place and not two readings of one rule.
Resolved.AnchorTransform = EleriStandPoint::ResolveAnchorTransform(Anchor);
// Remembered while the anchor is still loaded, that is the only chance to see where it is.
AnchorTransformCache.Add(Action->UsableObjectGuid, Resolved.AnchorTransform);
@@ -78,11 +90,6 @@ bool UScheduleSubsystem::ResolveAction(FGameplayTag NpcTag, int32 MinutesOfDay,
Resolved.AnchorTransform = *Cached;
}
if (AActor* RoamPath = FindAnchorActor(Action->RoamPathGuid))
{
Resolved.RoamPath = RoamPath;
}
OutAction = Resolved;
return true;
}
@@ -162,3 +169,50 @@ void UScheduleSubsystem::RebuildAnchorIndex()
}
}
}
void UScheduleSubsystem::CheckFrozenNpcPositioning()
{
if (NpcScheduleComponents.IsEmpty())
{
TArray<AActor*> OutActors;
UGameplayStatics::GetAllActorsOfClass(this, ANpcBaseCharacter::StaticClass(), OutActors);
for (const AActor* Actor : OutActors)
{
if (UNpcScheduleComponent* ScheduleComp = Actor->GetComponentByClass<UNpcScheduleComponent>())
{
NpcScheduleComponents.Add(ScheduleComp);
}
}
}
for (const UNpcScheduleComponent* Comp : NpcScheduleComponents)
{
FResolvedNpcAction ResolvedAction;
ResolveAction(Comp->NpcTag, GetTimeOfDayManager()->GetTimeOfDay().GetMinutesOfDay(), ResolvedAction);
if (ResolvedAction.Activity == ENpcActivity::Idle)
{
continue;
}
FVector AnchorLocation;
if (!ResolvedAction.GetAnchorLocation(AnchorLocation))
{
AnchorLocation = ResolvedAction.AnchorTransform.GetLocation();
}
if (FVector::DistSquared(Comp->GetOwner()->GetActorLocation(), AnchorLocation) >= 100.f * 100.f)
{
Comp->GetOwner()->SetActorLocation(AnchorLocation);
}
}
}
ATimeOfDayManager* UScheduleSubsystem::GetTimeOfDayManager()
{
if (!TimeOfDayManager)
{
TimeOfDayManager = Cast<ATimeOfDayManager>(UGameplayStatics::GetActorOfClass(this, ATimeOfDayManager::StaticClass()));
}
return TimeOfDayManager;
}

View File

@@ -8,6 +8,8 @@
#include "ProjectEleri/Ai/NpcScheduleAsset.h"
#include "ScheduleSubsystem.generated.h"
class ATimeOfDayManager;
class UNpcScheduleComponent;
class AActor;
USTRUCT(BlueprintType)
@@ -21,8 +23,8 @@ 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
* NPCs are marked non spatially loaded so they stay resident whatever the player does, but the beds
* and chairs 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.
*
@@ -59,6 +61,11 @@ protected:
/** Walks the loaded anchors once and fills LoadedAnchors from their PersistentIds */
void RebuildAnchorIndex();
UFUNCTION()
void CheckFrozenNpcPositioning();
ATimeOfDayManager* GetTimeOfDayManager();
/** Schedules keyed by the NPC tag that uses them, filled from the project settings on Initialize */
UPROPERTY()
@@ -69,15 +76,20 @@ protected:
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. */
UPROPERTY()
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;
UPROPERTY()
FTimerHandle SchedulePositioningCheckTimerHandle;
UPROPERTY()
TArray<UNpcScheduleComponent*> NpcScheduleComponents;
UPROPERTY()
ATimeOfDayManager* TimeOfDayManager = nullptr;
};

View File

@@ -0,0 +1,478 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "NpcScheduleActionCustomization.h"
#include "DetailLayoutBuilder.h"
#include "DetailWidgetRow.h"
#include "Editor.h"
#include "EngineUtils.h"
#include "Framework/MultiBox/MultiBoxBuilder.h"
#include "IDetailChildrenBuilder.h"
#include "Input/Reply.h"
#include "PropertyHandle.h"
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
#include "ScopedTransaction.h"
#include "Selection.h"
#include "Styling/AppStyle.h"
#include "UObject/UnrealType.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Input/SComboButton.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/Text/STextBlock.h"
#define LOCTEXT_NAMESPACE "ProjectEleriNpcSchedule"
namespace
{
/** The one field of FScheduledAction this customization takes over. It is the only member that is an
* id rather than a value, and so the only one that cannot be typed in. Names the UPROPERTY. */
const FName AnchorPropertyName(TEXT("UsableObjectGuid"));
}
TSharedRef<IPropertyTypeCustomization> FNpcScheduleActionCustomization::MakeInstance()
{
return MakeShareable(new FNpcScheduleActionCustomization());
}
bool FNpcScheduleActionCustomization::NeverExecutable()
{
return false;
}
void FNpcScheduleActionCustomization::CustomizeHeader(TSharedRef<IPropertyHandle> PropertyHandle, FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& CustomizationUtils)
{
// The panel's own name widget, which for an array element is the element's index. A struct with
// children has to have a header, and the index is the only sensible name for one action among
// several on the same NPC.
HeaderRow.NameContent()
[
PropertyHandle->CreatePropertyNameWidget()
];
}
void FNpcScheduleActionCustomization::CustomizeChildren(TSharedRef<IPropertyHandle> PropertyHandle, IDetailChildrenBuilder& ChildBuilder, IPropertyTypeCustomizationUtils& CustomizationUtils)
{
uint32 NumChildren = 0;
PropertyHandle->GetNumChildren(NumChildren);
// The declared order is kept, with the anchor row standing in for the field it replaces, so the
// panel reads top to bottom exactly as the struct is authored, the anchor included.
for (uint32 Index = 0; Index < NumChildren; ++Index)
{
TSharedPtr<IPropertyHandle> ChildHandle = PropertyHandle->GetChildHandle(Index);
if (!ChildHandle.IsValid())
{
continue;
}
const FProperty* ChildProperty = ChildHandle->GetProperty();
if (ChildProperty != nullptr && ChildProperty->GetFName() == AnchorPropertyName)
{
AddAnchorRow(ChildBuilder, ChildHandle.ToSharedRef());
continue;
}
ChildBuilder.AddProperty(ChildHandle.ToSharedRef());
}
}
void FNpcScheduleActionCustomization::AddAnchorRow(IDetailChildrenBuilder& ChildBuilder, TSharedRef<IPropertyHandle> AnchorHandle) const
{
// Shared with the widgets below rather than plain this, because the widgets outlive this call and are
// what call back into the panel: the panel is a shared object (the details panel builds it through
// MakeInstance), so holding a shared pointer with each widget means a widget can never call into a
// destroyed panel. Shared pointer rather than shared reference, because the binders take a raw
// pointer and TSharedRef hands back a reference from Get() while TSharedPtr hands back the pointer.
// The const is the const of this method: AsShared() hands back a reference to const, and every
// callback bound below is a const member, so nothing here needs to write to the panel.
const TSharedPtr<const FNpcScheduleActionCustomization> ThisRef = StaticCastSharedRef<const FNpcScheduleActionCustomization>(AsShared()).ToSharedPtr();
const TAttribute<FText> AnchorText = TAttribute<FText>::CreateSP(ThisRef.Get(), &FNpcScheduleActionCustomization::GetAnchorText, AnchorHandle);
const TAttribute<FText> AnchorTooltip = TAttribute<FText>::CreateSP(ThisRef.Get(), &FNpcScheduleActionCustomization::GetAnchorTooltip, AnchorHandle);
ChildBuilder.AddCustomRow(LOCTEXT("AnchorSearchString", "Anchor UsableObject PersistentId"))
.NameContent()
[
SNew(STextBlock)
.Text(LOCTEXT("AnchorLabel", "Anchor"))
.Font(IDetailLayoutBuilder::GetDetailFont())
.ToolTipText(AnchorTooltip)
]
.ValueContent()
.MinDesiredWidth(260.0f)
[
SNew(SHorizontalBox)
// What the id points at, which is the point of the row: a name rather than a string of hex.
+ SHorizontalBox::Slot()
.FillWidth(1.0f)
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.Text(AnchorText)
.ColorAndOpacity(TAttribute<FSlateColor>::CreateSP(ThisRef.Get(), &FNpcScheduleActionCustomization::GetAnchorColor, AnchorHandle))
.ToolTipText(AnchorTooltip)
.Font(IDetailLayoutBuilder::GetDetailFont())
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f)
.VAlign(VAlign_Center)
[
SNew(SComboButton)
.ToolTipText(LOCTEXT("AssignTooltip", "Assign the anchor from the current selection, or from the anchors in this level"))
.OnGetMenuContent(FOnGetContent::CreateSP(ThisRef.Get(), &FNpcScheduleActionCustomization::BuildAnchorMenu, AnchorHandle))
.ButtonContent()
[
SNew(STextBlock)
.Text(LOCTEXT("AssignLabel", "Assign"))
.Font(IDetailLayoutBuilder::GetDetailFont())
]
]
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
[
SNew(SButton)
.ButtonStyle(FAppStyle::Get(), "SimpleButton")
.ToolTipText(LOCTEXT("ClearTooltip", "Clear the anchor, leaving this action with no anchor at all"))
.IsEnabled(TAttribute<bool>::CreateSP(ThisRef.Get(), &FNpcScheduleActionCustomization::CanClearAnchor, AnchorHandle))
// A lambda rather than a bound method, because the click wants an FReply while the
// write itself wants to be a plain void that the menu entries can bind to as well.
.OnClicked(FOnClicked::CreateLambda([ThisRef, AnchorHandle]()
{
ThisRef->AssignAnchor(AnchorHandle, FGuid());
return FReply::Handled();
}))
[
SNew(STextBlock)
.Text(LOCTEXT("ClearLabel", "Unassign"))
.Font(IDetailLayoutBuilder::GetDetailFont())
]
]
];
}
FGuid FNpcScheduleActionCustomization::ReadAnchorGuid(const TSharedRef<IPropertyHandle>& AnchorHandle)
{
// No validity check, because a TSharedRef is a handle that exists: the panel only builds this row
// from a child handle it already has.
// Read as text rather than through AccessRawData, because the formatted value is exactly what the
// panel itself would show, and it reports no single value on a multi selection, which lands here as
// unassigned. That is the honest answer: several actions with different anchors have no one anchor.
FString GuidText;
if (AnchorHandle->GetValueAsFormattedString(GuidText) != FPropertyAccess::Success)
{
return FGuid();
}
FGuid Parsed;
FGuid::Parse(GuidText, Parsed);
return Parsed;
}
FText FNpcScheduleActionCustomization::GetAnchorText(TSharedRef<IPropertyHandle> AnchorHandle) const
{
const FGuid AnchorGuid = ReadAnchorGuid(AnchorHandle);
if (!AnchorGuid.IsValid())
{
return LOCTEXT("AnchorNone", "None");
}
if (const AActor* Anchor = FindActorWithGuid(AnchorGuid))
{
// More than one actor on the same id is said out loud, because this panel is the only place the
// author can see it before it happens in game: every store of the id, this action included,
// resolves to whichever of the actors a search reaches first, and which one that is is not
// something anybody can rely on.
const int32 Claimants = CountActorsWithGuid(AnchorGuid);
if (Claimants > 1)
{
return FText::Format(
LOCTEXT("AnchorDuplicated", "{0} (+{1} more share this id)"),
FText::FromString(Anchor->GetActorNameOrLabel()),
FText::AsNumber(Claimants - 1));
}
return FText::FromString(Anchor->GetActorNameOrLabel());
}
// The id is printed rather than reported as simply assigned, because an anchor that no actor in
// this level claims is a real state and not a broken one: a schedule pointing into a sublevel that
// is not loaded, or at furniture that has since been deleted. Printing the id is what tells those
// two apart, and it is the one thing that can then be searched for in the outliner or in a save.
return FText::Format(
LOCTEXT("AnchorMissing", "{0} (no such actor in this level)"),
FText::FromString(AnchorGuid.ToString(EGuidFormats::Digits)));
}
FText FNpcScheduleActionCustomization::GetAnchorTooltip(TSharedRef<IPropertyHandle> AnchorHandle) const
{
const FGuid AnchorGuid = ReadAnchorGuid(AnchorHandle);
// The raw id is in the tooltip on purpose. It is the only way to read it back, and reading it is
// what comparing an action against a log or a save needs, without the panel offering a text box
// that would let an id be typed in that no actor was ever asked for.
FText Tooltip = FText::Format(
LOCTEXT("AnchorTooltip", "PersistentId of the actor this action happens at, usually a bed or a chair. The id is taken from the actor itself by picking it, so it always belongs to a real actor.\n{0}"),
AnchorGuid.IsValid()
? FText::FromString(AnchorGuid.ToString(EGuidFormats::DigitsWithHyphens))
: LOCTEXT("AnchorTooltipNone", "Currently unassigned."));
// Grown rather than replaced, so an anchor whose id is claimed once reads exactly as it did before
// and only a duplicated id adds anything.
const int32 Claimants = CountActorsWithGuid(AnchorGuid);
if (Claimants > 1)
{
Tooltip = FText::Format(
LOCTEXT("AnchorTooltipDuplicated", "{0}\n{1} actors in this level claim this id, so this action walks the NPC to whichever of them is found first. Give all but one of them an id of their own."),
Tooltip,
FText::AsNumber(Claimants));
}
return Tooltip;
}
FSlateColor FNpcScheduleActionCustomization::GetAnchorColor(TSharedRef<IPropertyHandle> AnchorHandle) const
{
return ReadAnchorGuid(AnchorHandle).IsValid()
? FSlateColor::UseForeground()
: FSlateColor::UseSubduedForeground();
}
bool FNpcScheduleActionCustomization::CanClearAnchor(TSharedRef<IPropertyHandle> AnchorHandle) const
{
return ReadAnchorGuid(AnchorHandle).IsValid();
}
void FNpcScheduleActionCustomization::AssignAnchor(TSharedRef<IPropertyHandle> AnchorHandle, FGuid NewAnchorGuid) const
{
// One transaction around the write, so that assigning an anchor is a single step of undo however
// the action was reached. The outer objects are the ones modified, not the actor the id came from,
// because what is being edited is the schedule that stores the id rather than the anchor itself.
const FScopedTransaction Transaction(LOCTEXT("AssignAnchorTransaction", "Assign NPC Schedule Anchor"));
TArray<UObject*> OuterObjects;
AnchorHandle->GetOuterObjects(OuterObjects);
for (UObject* Outer : OuterObjects)
{
if (Outer != nullptr)
{
Outer->Modify();
}
}
// Written through the digits format, which is the one FGuid's text import reads back exactly, so
// the value never makes a round trip through a display format that then has to be parsed again. An
// invalid id is written the same way, and that is what clearing the anchor is.
AnchorHandle->SetValueFromFormattedString(NewAnchorGuid.ToString(EGuidFormats::Digits));
for (UObject* Outer : OuterObjects)
{
if (Outer != nullptr)
{
Outer->PostEditChange();
Outer->MarkPackageDirty();
}
}
}
TSharedRef<SWidget> FNpcScheduleActionCustomization::BuildAnchorMenu(TSharedRef<IPropertyHandle> AnchorHandle) const
{
// Const, like the row's: this method is const, AsShared() therefore hands back a reference to const,
// and both menu entries below bind const members of the panel. Shared pointer for the same reason as
// the row: the entries are what call back into the panel, and they outlive this call.
const TSharedPtr<const FNpcScheduleActionCustomization> ThisRef = StaticCastSharedRef<const FNpcScheduleActionCustomization>(AsShared()).ToSharedPtr();
FMenuBuilder MenuBuilder(true, nullptr);
const FGuid CurrentGuid = ReadAnchorGuid(AnchorHandle);
// The selection comes first, because the actor an anchor usually means is the one that was just
// clicked on in the level or the outliner, not one to be hunted down in a list of forty.
if (AActor* Selected = GetSelectedAnchorActor())
{
MenuBuilder.AddMenuEntry(
FText::Format(LOCTEXT("AssignSelection", "Assign selected actor: {0}"), FText::FromString(Selected->GetActorNameOrLabel())),
LOCTEXT("AssignSelectionTooltip", "Use the PersistentId of the actor currently selected in the level"),
FSlateIcon(),
FUIAction(FExecuteAction::CreateSP(ThisRef.Get(), &FNpcScheduleActionCustomization::AssignAnchor, AnchorHandle, GetAnchorId(Selected))));
}
else
{
// Explained rather than left out, so that the first entry of the menu is never a gap whose
// reason has to be guessed at.
MenuBuilder.AddMenuEntry(
LOCTEXT("NoSelection", "Select an actor in the level to assign it here"),
LOCTEXT("NoSelectionTooltip", "Nothing is selected, or what is selected cannot be an anchor"),
FSlateIcon(),
FUIAction(FExecuteAction(), FCanExecuteAction::CreateStatic(&FNpcScheduleActionCustomization::NeverExecutable)));
}
TArray<AActor*> Anchors;
CollectAnchorActors(Anchors);
if (Anchors.Num() > 0)
{
MenuBuilder.AddMenuSeparator();
const int32 NumListed = FMath::Min(Anchors.Num(), MaxListedAnchors);
for (int32 Index = 0; Index < NumListed; ++Index)
{
AActor* Anchor = Anchors[Index];
const FGuid AnchorGuid = GetAnchorId(Anchor);
// Radio entries on the id in use, so the menu doubles as the answer to the question the row
// raises when an anchor is already assigned: which of these actors is it.
MenuBuilder.AddMenuEntry(
FText::FromString(Anchor->GetActorNameOrLabel()),
FText::FromString(AnchorGuid.ToString(EGuidFormats::DigitsWithHyphens)),
FSlateIcon(),
FUIAction(
FExecuteAction::CreateSP(ThisRef.Get(), &FNpcScheduleActionCustomization::AssignAnchor, AnchorHandle, AnchorGuid),
FCanExecuteAction(),
FIsActionChecked::CreateLambda([CurrentGuid, AnchorGuid]() { return CurrentGuid == AnchorGuid; })),
NAME_None,
EUserInterfaceActionType::RadioButton);
}
if (Anchors.Num() > NumListed)
{
MenuBuilder.AddMenuEntry(
FText::Format(LOCTEXT("ListCapped", "{0} more anchors in this level"), FText::AsNumber(Anchors.Num() - NumListed)),
LOCTEXT("ListCappedTooltip", "The list is capped, so select the actor in the level and use the entry at the top of this menu instead"),
FSlateIcon(),
FUIAction(FExecuteAction(), FCanExecuteAction::CreateStatic(&FNpcScheduleActionCustomization::NeverExecutable)));
}
}
return MenuBuilder.MakeWidget();
}
AActor* FNpcScheduleActionCustomization::GetSelectedAnchorActor()
{
if (GEditor == nullptr)
{
return nullptr;
}
USelection* Selection = GEditor->GetSelectedActors();
if (Selection == nullptr)
{
return nullptr;
}
// The first selected actor that can be an anchor, so a selection that happens to include a light
// or a camera still offers the piece of furniture that is selected alongside it.
for (FSelectionIterator It(*Selection); It; ++It)
{
AActor* Actor = Cast<AActor>(*It);
if (Actor != nullptr && GetAnchorId(Actor).IsValid())
{
return Actor;
}
}
return nullptr;
}
FGuid FNpcScheduleActionCustomization::GetAnchorId(AActor* Actor)
{
if (Actor == nullptr)
{
return FGuid();
}
// Implementing the interface is the whole of the "can this actor be an anchor" rule, because the
// interface is what carries the id a schedule stores.
const IActorPersistentIdInterface* Identified = Cast<IActorPersistentIdInterface>(Actor);
return Identified != nullptr ? Identified->GetPersistentId() : FGuid();
}
AActor* FNpcScheduleActionCustomization::FindActorWithGuid(const FGuid& Guid)
{
if (!Guid.IsValid())
{
return nullptr;
}
UWorld* World = GEditor != nullptr ? GEditor->GetEditorWorldContext().World() : nullptr;
if (World == nullptr)
{
return nullptr;
}
// A plain actor walk rather than the schedule subsystem's anchor index, because the subsystem only
// exists while a game instance does, and the details panel has to work in the editor world, where
// no game is running at all.
for (TActorIterator<AActor> It(World); It; ++It)
{
AActor* Actor = *It;
if (Actor != nullptr && GetAnchorId(Actor) == Guid)
{
return Actor;
}
}
return nullptr;
}
int32 FNpcScheduleActionCustomization::CountActorsWithGuid(const FGuid& Guid)
{
if (!Guid.IsValid())
{
return 0;
}
UWorld* World = GEditor != nullptr ? GEditor->GetEditorWorldContext().World() : nullptr;
if (World == nullptr)
{
return 0;
}
int32 Claimants = 0;
for (TActorIterator<AActor> It(World); It; ++It)
{
AActor* Actor = *It;
if (Actor != nullptr && GetAnchorId(Actor) == Guid)
{
++Claimants;
}
}
return Claimants;
}
void FNpcScheduleActionCustomization::CollectAnchorActors(TArray<AActor*>& OutAnchors)
{
OutAnchors.Reset();
UWorld* World = GEditor != nullptr ? GEditor->GetEditorWorldContext().World() : nullptr;
if (World == nullptr)
{
return;
}
for (TActorIterator<AActor> It(World); It; ++It)
{
AActor* Actor = *It;
if (Actor != nullptr && GetAnchorId(Actor).IsValid())
{
OutAnchors.Add(Actor);
}
}
// Sorted by label, because the order a level walk hands actors over in is not stable, and a menu
// that reshuffles itself between two openings cannot be used twice in a row.
OutAnchors.Sort([](const AActor& A, const AActor& B)
{
return A.GetActorNameOrLabel() < B.GetActorNameOrLabel();
});
}
#undef LOCTEXT_NAMESPACE

View File

@@ -8,6 +8,7 @@
#include "ItemDataAssetCustomization.h"
#include "ItemEditorWindow.h"
#include "NpcScheduleActionCustomization.h"
#define LOCTEXT_NAMESPACE "ProjectEleriEditorModule"
@@ -24,6 +25,14 @@ void FProjectEleriEditorModule::StartupModule()
FOnGetDetailCustomizationInstance::CreateStatic(&FItemDataAssetCustomization::MakeInstance)
);
// Details customization for schedule actions, so an anchor is picked from the level instead of
// being typed in as a PersistentId. Registered under the struct's script name, which is the C++
// struct name without its F prefix.
PropEd.RegisterCustomPropertyTypeLayout(
TEXT("ScheduledAction"),
FOnGetPropertyTypeCustomizationInstance::CreateStatic(&FNpcScheduleActionCustomization::MakeInstance)
);
// Register the Item Editor as a docking tab.
if (FGlobalTabmanager::Get()->HasTabSpawner(ItemEditorTabName))
{
@@ -63,6 +72,7 @@ void FProjectEleriEditorModule::ShutdownModule()
{
FPropertyEditorModule& PropEd = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");
PropEd.UnregisterCustomClassLayout(TEXT("ItemDataAsset"));
PropEd.UnregisterCustomPropertyTypeLayout(TEXT("ScheduledAction"));
}
if (FGlobalTabmanager::Get()->HasTabSpawner(ItemEditorTabName))

View File

@@ -0,0 +1,97 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "IPropertyTypeCustomization.h"
#include "Styling/SlateColor.h"
class AActor;
class IPropertyHandle;
class SWidget;
/**
* Details panel layout for one FScheduledAction, the element type of
* UNpcScheduleAsset::ScheduledActions.
*
* An action's anchor is stored as a PersistentId rather than as an actor pointer, so that a re-cook or
* a re-save cannot leave a schedule dangling. The price of that is a value nobody can type in, so this
* customization replaces the raw Guid row with one that names the actor the id currently resolves to,
* offers the current selection and every anchor in the level in a menu, and has a button that clears
* it. The id therefore always comes from the actor itself and can never drift away from it. A row for
* an id that no actor in this level claims is left as it is, because that is a real state (furniture
* that was deleted, or furniture sitting in a sublevel that is not loaded) and not something to hide.
*
* Every other member of the struct keeps the row the details panel would have given it.
*/
class FNpcScheduleActionCustomization : public IPropertyTypeCustomization
{
public:
static TSharedRef<IPropertyTypeCustomization> MakeInstance();
virtual void CustomizeHeader(TSharedRef<IPropertyHandle> PropertyHandle, FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& CustomizationUtils) override;
virtual void CustomizeChildren(TSharedRef<IPropertyHandle> PropertyHandle, IDetailChildrenBuilder& ChildBuilder, IPropertyTypeCustomizationUtils& CustomizationUtils) override;
protected:
/** The anchor row: the label, the name of the actor the id resolves to, the assign menu, and the
* unassign button. Stands in for the raw Guid field, which is never shown. */
void AddAnchorRow(IDetailChildrenBuilder& ChildBuilder, TSharedRef<IPropertyHandle> AnchorHandle) const;
/** What the id points at: the actor's label, "None" when nothing is assigned, or the raw id when no
* actor in this level claims it. */
FText GetAnchorText(TSharedRef<IPropertyHandle> AnchorHandle) const;
/** The id itself and what it means, so that an id can be read off the panel and matched against a
* log or a save without the panel offering a text box to mistype it into. */
FText GetAnchorTooltip(TSharedRef<IPropertyHandle> AnchorHandle) const;
/** Subdues the row while nothing is assigned, so an action with no anchor reads differently at a
* glance from one whose anchor the panel cannot name. */
FSlateColor GetAnchorColor(TSharedRef<IPropertyHandle> AnchorHandle) const;
bool CanClearAnchor(TSharedRef<IPropertyHandle> AnchorHandle) const;
/** Writes one id into the action. An invalid id is what unassign does, so the button that clears
* the anchor and the menu entries that set one both come through here. */
void AssignAnchor(TSharedRef<IPropertyHandle> AnchorHandle, FGuid NewAnchorGuid) const;
/** The assign menu: the current selection first, then every anchor in the level, with the one in
* use radioed. */
TSharedRef<SWidget> BuildAnchorMenu(TSharedRef<IPropertyHandle> AnchorHandle) const;
/** The id a property handle currently holds, read as text so that it works whether the handle is
* reached on its own, inside the array, or through a multi selection, which reports no single
* value and so comes back as unassigned. */
static FGuid ReadAnchorGuid(const TSharedRef<IPropertyHandle>& AnchorHandle);
/** The first actor in the current selection that can be an anchor, or null when there is none. */
static AActor* GetSelectedAnchorActor();
/** The id of an actor, invalid when the actor cannot be an anchor. Implementing
* IActorPersistentIdInterface is the whole of that rule, since the interface is what carries the id
* a schedule stores. */
static FGuid GetAnchorId(AActor* Actor);
/** The actor a single id belongs to, or null when no actor in the current level claims it. */
static AActor* FindActorWithGuid(const FGuid& Guid);
/** How many actors in the current level claim an id. More than one is a real state - a pasted actor
* carries its source's id - and everything that stores the id, this schedule included, resolves to
* whichever of them a search reaches first, so the row says so rather than leaving it to be found
* in game. Walked on the spot rather than indexed, because this is read while the panel is drawn
* and an index would be stale the moment the author adds, deletes or pastes an anchor. */
static int32 CountActorsWithGuid(const FGuid& Guid);
/** Every actor in the current level that can be an anchor, sorted by label so that the menu does not
* reshuffle itself between two openings. */
static void CollectAnchorActors(TArray<AActor*>& OutAnchors);
/** For the menu entries that only exist to explain themselves, such as "nothing is selected". */
static bool NeverExecutable();
/** Past this many anchors the menu stops listing actors and says how many it left out, because a
* level can hold hundreds and a menu is not a browser. Assigning from the selection still works. */
static constexpr int32 MaxListedAnchors = 40;
};