Some housekeeping and some editor tools for schedules anchors
This commit is contained in:
@@ -195,7 +195,6 @@ MovementDisableEffectClass=/Script/Engine.BlueprintGeneratedClass'/Game/Blueprin
|
|||||||
+ProfessionExpArray=180000.000000
|
+ProfessionExpArray=180000.000000
|
||||||
+ProfessionExpArray=208000.000000
|
+ProfessionExpArray=208000.000000
|
||||||
+ProfessionExpArray=240000.000000
|
+ProfessionExpArray=240000.000000
|
||||||
+NpcDataAssets=/Game/GameData/Npc/DT_Test_Npc.DT_Test_Npc
|
|
||||||
NpcSchedules=(((TagName="NpcTags.Test"), "/Game/GameData/Npc/DT_Test_Npc_Schedule.DT_Test_Npc_Schedule"))
|
NpcSchedules=(((TagName="NpcTags.Test"), "/Game/GameData/Npc/DT_Test_Npc_Schedule.DT_Test_Npc_Schedule"))
|
||||||
GlobalSettingsDataSheet=/Game/GameData/CSV/GlobalKeyValueSettings.GlobalKeyValueSettings
|
GlobalSettingsDataSheet=/Game/GameData/CSV/GlobalKeyValueSettings.GlobalKeyValueSettings
|
||||||
SpriteShadowMaterial=/Game/Materials/Sprite/MI_SpriteShadow_Inst.MI_SpriteShadow_Inst
|
SpriteShadowMaterial=/Game/Materials/Sprite/MI_SpriteShadow_Inst.MI_SpriteShadow_Inst
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,43 +7,28 @@
|
|||||||
|
|
||||||
bool FScheduledAction::ContainsTimeOfDay(int32 MinutesOfDay) const
|
bool FScheduledAction::ContainsTimeOfDay(int32 MinutesOfDay) const
|
||||||
{
|
{
|
||||||
const int32 Start = StartTime.GetMinutesOfDay();
|
return MinutesOfDay >= StartTime.GetMinutesOfDay();
|
||||||
const int32 End = EndTime.GetMinutesOfDay();
|
|
||||||
|
|
||||||
// An action that starts and ends at the same minute is how "covers the whole day" is authored,
|
|
||||||
// a window of literally zero length would never be useful.
|
|
||||||
if (Start == End)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Start < End)
|
|
||||||
{
|
|
||||||
return MinutesOfDay >= Start && MinutesOfDay < End;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The window wraps past midnight, for example a 22:00 to 06:00 sleep. It is one continuous range,
|
|
||||||
// so everything from the start to midnight belongs to it as does everything from midnight to end.
|
|
||||||
return MinutesOfDay >= Start || MinutesOfDay < End;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FScheduledAction* UNpcScheduleAsset::FindActionForTime(int32 MinutesOfDay) const
|
const FScheduledAction* UNpcScheduleAsset::FindActionForTime(int32 MinutesOfDay) const
|
||||||
{
|
{
|
||||||
const FScheduledAction* Best = nullptr;
|
const FScheduledAction* Best = nullptr;
|
||||||
|
|
||||||
for (const FScheduledAction& Action : ScheduledActions)
|
if (ScheduledActions.Num() == 1)
|
||||||
{
|
{
|
||||||
|
return &(ScheduledActions[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 i = 0; i < ScheduledActions.Num() - 1; ++i)
|
||||||
|
{
|
||||||
|
const FScheduledAction& Action = ScheduledActions[i];
|
||||||
if (!Action.ContainsTimeOfDay(MinutesOfDay))
|
if (!Action.ContainsTimeOfDay(MinutesOfDay))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strictly greater, so the action authored earlier in the list wins a priority tie and the
|
Best = &Action;
|
||||||
// result stays stable no matter what order the windows were visited in.
|
break;
|
||||||
if (Best == nullptr || Action.Priority > Best->Priority)
|
|
||||||
{
|
|
||||||
Best = &Action;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Best;
|
return Best;
|
||||||
@@ -68,10 +53,13 @@ bool FResolvedNpcAction::GetAnchorTransform(FTransform& OutTransform) const
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streamed out. An invalid Guid means this action never had an anchor to lose track of, which is
|
// Not in front of the schedule subsystem, so whether there is still a place to be is the flag's
|
||||||
// a different thing from one that cannot be seen at the moment: only the latter is a place, and
|
// answer rather than something to infer from the Guid. An action with no anchor has no place, and
|
||||||
// AnchorTransform is where it was the last time the schedule subsystem could see it.
|
// neither has one whose anchor nobody has seen: not when its cell has not been loaded, and not
|
||||||
if (!bAnchorResolved && AnchorGuid.IsValid())
|
// when the schedule was saved before it recorded a place of its own. Both of those used to be
|
||||||
|
// answered with AnchorTransform, which is the identity, which is a place at the origin of the
|
||||||
|
// world, and an NPC sent there never arrives.
|
||||||
|
if (bHasAnchorTransform)
|
||||||
{
|
{
|
||||||
OutTransform = AnchorTransform;
|
OutTransform = AnchorTransform;
|
||||||
return true;
|
return true;
|
||||||
@@ -91,4 +79,3 @@ bool FResolvedNpcAction::GetAnchorLocation(FVector& OutLocation) const
|
|||||||
OutLocation = ResolvedAnchor.GetLocation();
|
OutLocation = ResolvedAnchor.GetLocation();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ struct FScheduledAction {
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadOnly)
|
UPROPERTY(EditAnywhere, BlueprintReadOnly)
|
||||||
FTimeOfDayData StartTime;
|
FTimeOfDayData StartTime;
|
||||||
|
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadOnly)
|
|
||||||
FTimeOfDayData EndTime;
|
|
||||||
|
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadOnly)
|
UPROPERTY(EditAnywhere, BlueprintReadOnly)
|
||||||
ENpcActivity Activity = ENpcActivity::Idle;
|
ENpcActivity Activity = ENpcActivity::Idle;
|
||||||
|
|
||||||
@@ -47,9 +44,26 @@ struct FScheduledAction {
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (DisplayName = "Anchor"))
|
UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (DisplayName = "Anchor"))
|
||||||
FGuid UsableObjectGuid;
|
FGuid UsableObjectGuid;
|
||||||
|
|
||||||
/** Higher wins when two windows overlap. Equal priorities fall back to authoring order. */
|
/** Where that anchor stood when it was picked, worked out through the same rule the game reads
|
||||||
|
* (EleriStandPoint::ResolveAnchorTransform) so that an action whose anchor sits in a cell nobody
|
||||||
|
* has loaded still has somewhere to send the NPC. Written by FNpcScheduleActionCustomization as
|
||||||
|
* part of picking the anchor, which is the only way it is ever written: it is not a value to be
|
||||||
|
* typed in, and the panel keeps no row for it, exactly as it keeps none for the id.
|
||||||
|
*
|
||||||
|
* A cache of the anchor rather than a part of what the schedule means, so the two readers that can
|
||||||
|
* see the furniture itself both prefer their own answer to this one: FResolvedNpcAction asks the
|
||||||
|
* live actor, UScheduleSubsystem::ResolveAction asks what it remembered of it while it was loaded,
|
||||||
|
* and only an anchor nobody has seen answers from here. Meaningful only when bHasAnchorTransform
|
||||||
|
* is set. */
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadOnly)
|
UPROPERTY(EditAnywhere, BlueprintReadOnly)
|
||||||
int32 Priority = 0;
|
FTransform AnchorTransform = FTransform::Identity;
|
||||||
|
|
||||||
|
/** True when AnchorTransform is a place to send an NPC to at all. Said out loud rather than read
|
||||||
|
* off the transform, because Identity is a place like any other and a snapshot of an anchor
|
||||||
|
* standing at the origin of a level has to be told apart from a schedule with nowhere to send
|
||||||
|
* anyone. */
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadOnly)
|
||||||
|
bool bHasAnchorTransform = false;
|
||||||
|
|
||||||
/** True when MinutesOfDay falls inside this window. The start is inclusive and the end exclusive,
|
/** True when MinutesOfDay falls inside this window. The start is inclusive and the end exclusive,
|
||||||
* so a 06:00-22:00 and a 22:00-06:00 window tile a whole day without overlapping. Windows that
|
* so a 06:00-22:00 and a 22:00-06:00 window tile a whole day without overlapping. Windows that
|
||||||
@@ -91,33 +105,45 @@ struct FResolvedNpcAction
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||||
TWeakObjectPtr<AActor> Anchor;
|
TWeakObjectPtr<AActor> Anchor;
|
||||||
|
|
||||||
/** Where that anchor was the last time the schedule subsystem could see it. This is the fallback
|
/** Where that anchor is, and whatever the resolver could answer with: the actor itself while it is
|
||||||
* used when Anchor is null, because the NPCs themselves never stream out but the furniture they
|
* loaded, where the schedule subsystem last saw it while it was loaded earlier in this session,
|
||||||
* walk to does. */
|
* or where the schedule itself recorded it standing when the anchor was picked. This is what a
|
||||||
|
* caller reads while Anchor is null, because the NPCs themselves never stream out but the
|
||||||
|
* furniture they walk to does. bHasAnchorTransform says whether any of the three happened. */
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||||
FTransform AnchorTransform = FTransform::Identity;
|
FTransform AnchorTransform = FTransform::Identity;
|
||||||
|
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||||
FGuid AnchorGuid;
|
FGuid AnchorGuid;
|
||||||
|
|
||||||
/** True when Anchor points at a live actor. When it is false and AnchorGuid is valid the anchor
|
/** True when Anchor points at a live actor. When it is false the anchor is not in front of the
|
||||||
* has streamed out and AnchorTransform holds where it was last seen. */
|
* schedule subsystem, and whether there is still a place to be is the flag below rather than
|
||||||
|
* something to be read off the transforms. */
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||||
bool bAnchorResolved = false;
|
bool bAnchorResolved = false;
|
||||||
|
|
||||||
|
/** True when AnchorTransform is where this action happens. False means there is nowhere to be,
|
||||||
|
* which is the answer both for an action with no anchor and for one whose anchor nobody has seen,
|
||||||
|
* whether because its cell has not been loaded or because the schedule was saved before it
|
||||||
|
* recorded a place of its own. Carried beside the transform so that no caller has to guess at it,
|
||||||
|
* and the same rule as FScheduledAction::bHasAnchorTransform. */
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||||
|
bool bHasAnchorTransform = false;
|
||||||
|
|
||||||
/** Identity for the "did this NPC's assignment actually change" check. Deliberately ignores the
|
/** 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. */
|
* transforms, moving a bed while an NPC is already asleep should not restart the StateTree. */
|
||||||
bool IsSameAssignment(const FResolvedNpcAction& Other) const;
|
bool IsSameAssignment(const FResolvedNpcAction& Other) const;
|
||||||
|
|
||||||
/** Where this action happens, with the rotation the anchor is authored at. False means there is
|
/** 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
|
* 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.
|
* anchor has never been seen, which is what bHasAnchorTransform is for.
|
||||||
*
|
*
|
||||||
* A live anchor actor wins over the remembered transform, because that actor is where the bed
|
* A live anchor actor wins over the transform beside it, 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
|
* actually is right now, and the transform is only ever a record of where it was: what the
|
||||||
* cell went. Both answers are the same thing to a caller, which is just somewhere to stand and
|
* schedule subsystem remembered of it while it was loaded, or, when nobody has seen it at all,
|
||||||
* a way to face, and it is why this can be re-read every tick and stay right without the caller
|
* what the schedule wrote down when the anchor was picked in the editor. All of them are the same
|
||||||
* knowing anything about streaming.
|
* 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
|
* 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
|
* is answered for by that stand point rather than by the anchor's own origin. For a bench the
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ bool UNpcScheduleComponent::RefreshSchedule()
|
|||||||
// would never actually get anywhere.
|
// would never actually get anywhere.
|
||||||
CurrentAction.Anchor = Resolved.Anchor;
|
CurrentAction.Anchor = Resolved.Anchor;
|
||||||
CurrentAction.AnchorTransform = Resolved.AnchorTransform;
|
CurrentAction.AnchorTransform = Resolved.AnchorTransform;
|
||||||
|
CurrentAction.bHasAnchorTransform = Resolved.bHasAnchorTransform;
|
||||||
CurrentAction.bAnchorResolved = Resolved.bAnchorResolved;
|
CurrentAction.bAnchorResolved = Resolved.bAnchorResolved;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,6 @@ public:
|
|||||||
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ability System|Professions")
|
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ability System|Professions")
|
||||||
TArray<float> ProfessionExpArray;
|
TArray<float> ProfessionExpArray;
|
||||||
|
|
||||||
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ai")
|
|
||||||
TArray<TSoftObjectPtr<UNpcDataAsset>> NpcDataAssets;
|
|
||||||
|
|
||||||
/** The day plan every NPC follows, keyed by the tag placed on its NpcScheduleComponent. Many NPCs
|
/** The day plan every NPC follows, keyed by the tag placed on its NpcScheduleComponent. Many NPCs
|
||||||
* can share one entry, which is the point of going through a tag at all. */
|
* can share one entry, which is the point of going through a tag at all. */
|
||||||
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ai|Schedule", meta = (ForceInlineRow, Categories = "NpcTags"))
|
UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Ai|Schedule", meta = (ForceInlineRow, Categories = "NpcTags"))
|
||||||
|
|||||||
@@ -82,12 +82,30 @@ bool UScheduleSubsystem::ResolveAction(FGameplayTag NpcTag, int32 MinutesOfDay,
|
|||||||
// has streamed out are one place and not two readings of one rule.
|
// has streamed out are one place and not two readings of one rule.
|
||||||
Resolved.AnchorTransform = EleriStandPoint::ResolveAnchorTransform(Anchor);
|
Resolved.AnchorTransform = EleriStandPoint::ResolveAnchorTransform(Anchor);
|
||||||
|
|
||||||
|
// Said out loud here as well as in the two branches below, because this is the one caller that
|
||||||
|
// can ask the actor itself and the flag is what the others leave behind: it means "this action
|
||||||
|
// has somewhere to be", not "this action had to remember where".
|
||||||
|
Resolved.bHasAnchorTransform = true;
|
||||||
|
|
||||||
// Remembered while the anchor is still loaded, that is the only chance to see where it is.
|
// Remembered while the anchor is still loaded, that is the only chance to see where it is.
|
||||||
AnchorTransformCache.Add(Action->UsableObjectGuid, Resolved.AnchorTransform);
|
AnchorTransformCache.Add(Action->UsableObjectGuid, Resolved.AnchorTransform);
|
||||||
}
|
}
|
||||||
else if (const FTransform* Cached = AnchorTransformCache.Find(Action->UsableObjectGuid))
|
else if (const FTransform* Cached = AnchorTransformCache.Find(Action->UsableObjectGuid))
|
||||||
{
|
{
|
||||||
|
// Seen while it was loaded at some point this session, which beats what the schedule was saved
|
||||||
|
// with: this is where the anchor actually is rather than where it was the day somebody picked
|
||||||
|
// it in the editor.
|
||||||
Resolved.AnchorTransform = *Cached;
|
Resolved.AnchorTransform = *Cached;
|
||||||
|
Resolved.bHasAnchorTransform = true;
|
||||||
|
}
|
||||||
|
else if (Action->bHasAnchorTransform)
|
||||||
|
{
|
||||||
|
// Nobody has seen it this session, so where the schedule recorded it standing when the anchor
|
||||||
|
// was picked is the only answer there is. It is what keeps an NPC's night from depending on a
|
||||||
|
// cell having been loaded once already, and it is deliberately not written into the cache
|
||||||
|
// above: that map means "seen while loaded", and an authored transform is not that.
|
||||||
|
Resolved.AnchorTransform = Action->AnchorTransform;
|
||||||
|
Resolved.bHasAnchorTransform = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
OutAction = Resolved;
|
OutAction = Resolved;
|
||||||
@@ -198,7 +216,12 @@ void UScheduleSubsystem::CheckFrozenNpcPositioning()
|
|||||||
FVector AnchorLocation;
|
FVector AnchorLocation;
|
||||||
if (!ResolvedAction.GetAnchorLocation(AnchorLocation))
|
if (!ResolvedAction.GetAnchorLocation(AnchorLocation))
|
||||||
{
|
{
|
||||||
AnchorLocation = ResolvedAction.AnchorTransform.GetLocation();
|
// Nowhere to be, so it is left standing where it is, which is what the callers that walk an
|
||||||
|
// NPC with this same answer do with it. The fallback that used to be here read
|
||||||
|
// AnchorTransform, and that transform is the identity whenever there is no place to
|
||||||
|
// remember, so a frozen NPC whose anchor had never been seen was being teleported to the
|
||||||
|
// origin of the world.
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (FVector::DistSquared(Comp->GetOwner()->GetActorLocation(), AnchorLocation) >= 100.f * 100.f)
|
if (FVector::DistSquared(Comp->GetOwner()->GetActorLocation(), AnchorLocation) >= 100.f * 100.f)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
#include "IDetailChildrenBuilder.h"
|
#include "IDetailChildrenBuilder.h"
|
||||||
#include "Input/Reply.h"
|
#include "Input/Reply.h"
|
||||||
#include "PropertyHandle.h"
|
#include "PropertyHandle.h"
|
||||||
|
#include "ProjectEleri/GameObjects/EleriStandPoint.h"
|
||||||
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
|
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
|
||||||
#include "ScopedTransaction.h"
|
#include "ScopedTransaction.h"
|
||||||
#include "Selection.h"
|
#include "Selection.h"
|
||||||
#include "Styling/AppStyle.h"
|
#include "Styling/AppStyle.h"
|
||||||
|
#include "UObject/Class.h"
|
||||||
#include "UObject/UnrealType.h"
|
#include "UObject/UnrealType.h"
|
||||||
#include "Widgets/Input/SButton.h"
|
#include "Widgets/Input/SButton.h"
|
||||||
#include "Widgets/Input/SComboButton.h"
|
#include "Widgets/Input/SComboButton.h"
|
||||||
@@ -27,6 +29,12 @@ namespace
|
|||||||
/** The one field of FScheduledAction this customization takes over. It is the only member that is an
|
/** 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. */
|
* id rather than a value, and so the only one that cannot be typed in. Names the UPROPERTY. */
|
||||||
const FName AnchorPropertyName(TEXT("UsableObjectGuid"));
|
const FName AnchorPropertyName(TEXT("UsableObjectGuid"));
|
||||||
|
|
||||||
|
/** The other half of the same field: where the anchor stood when it was picked, and whether that
|
||||||
|
* was worked out at all. Written by the pick and by the unassign button and never by hand, so
|
||||||
|
* like the id they keep no rows of their own and are reported through the anchor row's tooltip. */
|
||||||
|
const FName AnchorTransformPropertyName(TEXT("AnchorTransform"));
|
||||||
|
const FName HasAnchorTransformPropertyName(TEXT("bHasAnchorTransform"));
|
||||||
}
|
}
|
||||||
|
|
||||||
TSharedRef<IPropertyTypeCustomization> FNpcScheduleActionCustomization::MakeInstance()
|
TSharedRef<IPropertyTypeCustomization> FNpcScheduleActionCustomization::MakeInstance()
|
||||||
@@ -69,6 +77,22 @@ void FNpcScheduleActionCustomization::CustomizeChildren(TSharedRef<IPropertyHand
|
|||||||
if (ChildProperty != nullptr && ChildProperty->GetFName() == AnchorPropertyName)
|
if (ChildProperty != nullptr && ChildProperty->GetFName() == AnchorPropertyName)
|
||||||
{
|
{
|
||||||
AddAnchorRow(ChildBuilder, ChildHandle.ToSharedRef());
|
AddAnchorRow(ChildBuilder, ChildHandle.ToSharedRef());
|
||||||
|
|
||||||
|
// Straight under the anchor it belongs to, because the two rows are two halves of one
|
||||||
|
// answer: which anchor, and where that anchor was when it was picked. Reads before the two
|
||||||
|
// fields the record is stored in, which follow the id in the struct and hold no row of
|
||||||
|
// their own.
|
||||||
|
AddAnchorLocationRow(ChildBuilder, ChildHandle.ToSharedRef());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two fields the record is stored in. A cache of the anchor rather than part of the action as
|
||||||
|
// authored, so neither holds a row anything could be typed into: the place itself is reported by
|
||||||
|
// the Anchor Location row above, built from the same id, and the rest of what there is to know
|
||||||
|
// about the pair is in the anchor row's tooltip.
|
||||||
|
if (ChildProperty != nullptr
|
||||||
|
&& (ChildProperty->GetFName() == AnchorTransformPropertyName || ChildProperty->GetFName() == HasAnchorTransformPropertyName))
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +180,42 @@ void FNpcScheduleActionCustomization::AddAnchorRow(IDetailChildrenBuilder& Child
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void FNpcScheduleActionCustomization::AddAnchorLocationRow(IDetailChildrenBuilder& ChildBuilder, TSharedRef<IPropertyHandle> AnchorHandle) const
|
||||||
|
{
|
||||||
|
// Held exactly the way the anchor row holds it, and for the same reason: these widgets outlive the
|
||||||
|
// call that built them and are what call back into the panel, so each one carries a shared pointer
|
||||||
|
// and can never reach a panel that has gone.
|
||||||
|
const TSharedPtr<const FNpcScheduleActionCustomization> ThisRef = StaticCastSharedRef<const FNpcScheduleActionCustomization>(AsShared()).ToSharedPtr();
|
||||||
|
|
||||||
|
const TAttribute<FText> LocationText = TAttribute<FText>::CreateSP(ThisRef.Get(), &FNpcScheduleActionCustomization::GetAnchorLocationText, AnchorHandle);
|
||||||
|
const TAttribute<FText> LocationTooltip = TAttribute<FText>::CreateSP(ThisRef.Get(), &FNpcScheduleActionCustomization::GetAnchorLocationTooltip, AnchorHandle);
|
||||||
|
|
||||||
|
// A row of text and nothing else, which is what makes it read only: there is no widget here that
|
||||||
|
// could write the record, so nothing about it has to be disabled or guarded, and nothing an author
|
||||||
|
// does to this row can put an NPC somewhere the anchor is not.
|
||||||
|
//
|
||||||
|
// No invalidation is wired up either, because the attributes above are re-evaluated on every paint
|
||||||
|
// and each one reads the action and the level again as it goes: picking an anchor, moving one, or
|
||||||
|
// running Recheck Schedules shows here without anything having to say so.
|
||||||
|
ChildBuilder.AddCustomRow(LOCTEXT("AnchorLocationSearchString", "Anchor Location AnchorTransform"))
|
||||||
|
.NameContent()
|
||||||
|
[
|
||||||
|
SNew(STextBlock)
|
||||||
|
.Text(LOCTEXT("AnchorLocationLabel", "Anchor Location"))
|
||||||
|
.Font(IDetailLayoutBuilder::GetDetailFont())
|
||||||
|
.ToolTipText(LocationTooltip)
|
||||||
|
]
|
||||||
|
.ValueContent()
|
||||||
|
.MinDesiredWidth(260.0f)
|
||||||
|
[
|
||||||
|
SNew(STextBlock)
|
||||||
|
.Text(LocationText)
|
||||||
|
.ColorAndOpacity(TAttribute<FSlateColor>::CreateSP(ThisRef.Get(), &FNpcScheduleActionCustomization::GetAnchorLocationColor, AnchorHandle))
|
||||||
|
.ToolTipText(LocationTooltip)
|
||||||
|
.Font(IDetailLayoutBuilder::GetDetailFont())
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
FGuid FNpcScheduleActionCustomization::ReadAnchorGuid(const TSharedRef<IPropertyHandle>& AnchorHandle)
|
FGuid FNpcScheduleActionCustomization::ReadAnchorGuid(const TSharedRef<IPropertyHandle>& AnchorHandle)
|
||||||
{
|
{
|
||||||
// No validity check, because a TSharedRef is a handle that exists: the panel only builds this row
|
// No validity check, because a TSharedRef is a handle that exists: the panel only builds this row
|
||||||
@@ -175,6 +235,112 @@ FGuid FNpcScheduleActionCustomization::ReadAnchorGuid(const TSharedRef<IProperty
|
|||||||
return Parsed;
|
return Parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TSharedPtr<IPropertyHandle> FNpcScheduleActionCustomization::GetAnchorSibling(const TSharedRef<IPropertyHandle>& AnchorHandle, FName ChildName)
|
||||||
|
{
|
||||||
|
// The parent of a field of a struct is the struct itself, which is the handle this customization
|
||||||
|
// was built for, so a sibling is one lookup away. The parent is checked because a handle that is
|
||||||
|
// not a child of a struct node has none and would be followed off the end of the tree, while the
|
||||||
|
// anchor handle itself needs no check: a shared reference is a handle that exists.
|
||||||
|
const TSharedPtr<IPropertyHandle> ParentHandle = AnchorHandle->GetParentHandle();
|
||||||
|
return ParentHandle.IsValid() ? ParentHandle->GetChildHandle(ChildName) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FNpcScheduleActionCustomization::ResolveAnchorSnapshot(const FGuid& AnchorGuid, FTransform& OutTransform)
|
||||||
|
{
|
||||||
|
OutTransform = FTransform::Identity;
|
||||||
|
|
||||||
|
// The actor the schedule resolves to in game, asked for exactly the way the row names it, so that
|
||||||
|
// what is stored here and what the row shows above it are one reading of one rule rather than two
|
||||||
|
// that can come apart.
|
||||||
|
const AActor* Anchor = FindActorWithGuid(AnchorGuid);
|
||||||
|
if (Anchor == nullptr)
|
||||||
|
{
|
||||||
|
// Nothing in this level claims the id. Stored as "no place" rather than as a place at the
|
||||||
|
// origin: the schedule has an id and no idea where it is, which is a state a running game can
|
||||||
|
// still resolve on its own once somebody loads the anchor, and not one to invent a location
|
||||||
|
// for.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Through the runtime rule rather than through the actor's own transform, because for furniture
|
||||||
|
// those two are not the same place: a bench's origin sits over the hole the bench leaves in the
|
||||||
|
// navmesh, and where an NPC can actually stand for one is authored on it. See EleriStandPoint.
|
||||||
|
OutTransform = EleriStandPoint::ResolveAnchorTransform(Anchor);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
TOptional<FTransform> FNpcScheduleActionCustomization::GetCachedAnchorTransform(const TSharedRef<IPropertyHandle>& AnchorHandle) const
|
||||||
|
{
|
||||||
|
// Nothing to report is a normal answer here rather than a failure: an action assigned before there
|
||||||
|
// was anywhere to store this has no snapshot, and neither has one that was just cleared, and both
|
||||||
|
// read as an anchor with nowhere recorded instead of as a place at the origin.
|
||||||
|
const TSharedPtr<IPropertyHandle> HasTransformHandle = GetAnchorSibling(AnchorHandle, HasAnchorTransformPropertyName);
|
||||||
|
if (!HasTransformHandle.IsValid())
|
||||||
|
{
|
||||||
|
return TOptional<FTransform>();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool bHasTransform = false;
|
||||||
|
if (HasTransformHandle->GetValue(bHasTransform) != FPropertyAccess::Success || !bHasTransform)
|
||||||
|
{
|
||||||
|
return TOptional<FTransform>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const TSharedPtr<IPropertyHandle> TransformHandle = GetAnchorSibling(AnchorHandle, AnchorTransformPropertyName);
|
||||||
|
if (!TransformHandle.IsValid())
|
||||||
|
{
|
||||||
|
return TOptional<FTransform>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The type is checked before the address below is reinterpreted as an FTransform, so that a field
|
||||||
|
// which is not one is left unread rather than read through the wrong layout.
|
||||||
|
const FStructProperty* TransformProperty = CastField<FStructProperty>(TransformHandle->GetProperty());
|
||||||
|
if (TransformProperty == nullptr || TransformProperty->Struct != TBaseStructure<FTransform>::Get())
|
||||||
|
{
|
||||||
|
return TOptional<FTransform>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read as raw data, which is how it is written below and so the reading that cannot disagree with
|
||||||
|
// it. Anything other than one address is a row standing for several actions at once, and there is
|
||||||
|
// no one place to report for several of them, exactly as there is no one anchor to name above.
|
||||||
|
TArray<const void*> RawData;
|
||||||
|
TransformHandle->AccessRawData(RawData);
|
||||||
|
if (RawData.Num() != 1 || RawData[0] == nullptr)
|
||||||
|
{
|
||||||
|
return TOptional<FTransform>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return TOptional<FTransform>(*static_cast<const FTransform*>(RawData[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
void FNpcScheduleActionCustomization::WriteCachedAnchorTransform(const TSharedRef<IPropertyHandle>& AnchorTransformHandle, const FTransform& Snapshot)
|
||||||
|
{
|
||||||
|
// Checked rather than assumed, for the same reason as the read: the addresses below are written
|
||||||
|
// through as FTransforms, and a property that is not one would be written through the wrong
|
||||||
|
// layout.
|
||||||
|
const FStructProperty* TransformProperty = CastField<FStructProperty>(AnchorTransformHandle->GetProperty());
|
||||||
|
if (TransformProperty == nullptr || TransformProperty->Struct != TBaseStructure<FTransform>::Get())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The change notification a raw write has to raise by hand. It is the reason the flag beside this
|
||||||
|
// is written with SetValue and this is not: the typed setter raises it itself.
|
||||||
|
AnchorTransformHandle->NotifyPreChange();
|
||||||
|
|
||||||
|
TArray<void*> RawData;
|
||||||
|
AnchorTransformHandle->AccessRawData(RawData);
|
||||||
|
for (void* Raw : RawData)
|
||||||
|
{
|
||||||
|
if (Raw != nullptr)
|
||||||
|
{
|
||||||
|
*static_cast<FTransform*>(Raw) = Snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnchorTransformHandle->NotifyPostChange(EPropertyChangeType::ValueSet);
|
||||||
|
}
|
||||||
|
|
||||||
FText FNpcScheduleActionCustomization::GetAnchorText(TSharedRef<IPropertyHandle> AnchorHandle) const
|
FText FNpcScheduleActionCustomization::GetAnchorText(TSharedRef<IPropertyHandle> AnchorHandle) const
|
||||||
{
|
{
|
||||||
const FGuid AnchorGuid = ReadAnchorGuid(AnchorHandle);
|
const FGuid AnchorGuid = ReadAnchorGuid(AnchorHandle);
|
||||||
@@ -234,6 +400,18 @@ FText FNpcScheduleActionCustomization::GetAnchorTooltip(TSharedRef<IPropertyHand
|
|||||||
FText::AsNumber(Claimants));
|
FText::AsNumber(Claimants));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grown rather than replaced, like the warning above, and only when a place is stored: a schedule
|
||||||
|
// that still resolves with its furniture's cell streamed out is a property of the action worth
|
||||||
|
// being able to see, while an action with no snapshot is every action authored before snapshots
|
||||||
|
// existed rather than something wrong with this one.
|
||||||
|
if (const TOptional<FTransform> AnchorSnapshot = GetCachedAnchorTransform(AnchorHandle))
|
||||||
|
{
|
||||||
|
Tooltip = FText::Format(
|
||||||
|
LOCTEXT("AnchorTooltipSnapshot", "{0}\nPicked at {1}, which is where this action sends the NPC while the anchor is streamed out and has never been seen loaded."),
|
||||||
|
Tooltip,
|
||||||
|
FText::FromString(AnchorSnapshot->GetLocation().ToCompactString()));
|
||||||
|
}
|
||||||
|
|
||||||
return Tooltip;
|
return Tooltip;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +422,199 @@ FSlateColor FNpcScheduleActionCustomization::GetAnchorColor(TSharedRef<IProperty
|
|||||||
: FSlateColor::UseSubduedForeground();
|
: FSlateColor::UseSubduedForeground();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FNpcScheduleActionCustomization::EAnchorLocationState FNpcScheduleActionCustomization::GetAnchorLocationState(TSharedRef<IPropertyHandle> AnchorHandle, TOptional<FTransform>& OutSnapshot, FTransform& OutLiveTransform) const
|
||||||
|
{
|
||||||
|
// Both handed back empty, so that a caller which only asks about the state cannot come away with a
|
||||||
|
// transform left over from the last time the row was painted.
|
||||||
|
OutSnapshot = TOptional<FTransform>();
|
||||||
|
OutLiveTransform = FTransform::Identity;
|
||||||
|
|
||||||
|
const FGuid AnchorGuid = ReadAnchorGuid(AnchorHandle);
|
||||||
|
if (!AnchorGuid.IsValid())
|
||||||
|
{
|
||||||
|
// No anchor, asked the way the anchor row above asks it, so that the two rows cannot end up
|
||||||
|
// disagreeing about whether there is an anchor at all.
|
||||||
|
return EAnchorLocationState::NoAnchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
OutSnapshot = GetCachedAnchorTransform(AnchorHandle);
|
||||||
|
if (!OutSnapshot)
|
||||||
|
{
|
||||||
|
// An anchor with no place recorded. A real state rather than a failure: every action whose
|
||||||
|
// anchor was picked before there was anywhere to store one reads exactly like this, and so does
|
||||||
|
// one whose anchor was just assigned from somewhere that cannot see the level.
|
||||||
|
return EAnchorLocationState::NotRecorded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where that anchor stands now, through the game's own rule, which is the same call the pick uses to
|
||||||
|
// write the record and Recheck Schedules uses to refresh it. Asking this way is what keeps the panel
|
||||||
|
// and the game from putting the same NPC in two different places.
|
||||||
|
FTransform ResolvedTransform;
|
||||||
|
if (!ResolveAnchorSnapshot(AnchorGuid, ResolvedTransform))
|
||||||
|
{
|
||||||
|
// Nothing in this level claims the id. Either the furniture's cell is not loaded or the
|
||||||
|
// furniture is gone, and nothing here can tell those two apart, so the record is neither
|
||||||
|
// confirmed nor called stale.
|
||||||
|
return EAnchorLocationState::Unresolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
OutLiveTransform = ResolvedTransform;
|
||||||
|
|
||||||
|
// The whole transform rather than the point, deliberately the same comparison as
|
||||||
|
// ScheduleAssetRecheck.cpp uses to decide whether a schedule has drifted: which way an anchor faces
|
||||||
|
// is part of where an NPC is put, so a bed turned on the spot has to read as a difference here if
|
||||||
|
// the sweep would rewrite it. The two are kept equal by hand, each with a note like this one,
|
||||||
|
// because a panel that called a record stale which the sweep would leave alone is a bug that is
|
||||||
|
// otherwise invisible until somebody runs the sweep and is told that nothing changed.
|
||||||
|
return OutSnapshot->Equals(OutLiveTransform)
|
||||||
|
? EAnchorLocationState::UpToDate
|
||||||
|
: EAnchorLocationState::Differs;
|
||||||
|
}
|
||||||
|
|
||||||
|
FText FNpcScheduleActionCustomization::GetAnchorLocationText(TSharedRef<IPropertyHandle> AnchorHandle) const
|
||||||
|
{
|
||||||
|
TOptional<FTransform> Snapshot;
|
||||||
|
FTransform LiveTransform;
|
||||||
|
const EAnchorLocationState State = GetAnchorLocationState(AnchorHandle, Snapshot, LiveTransform);
|
||||||
|
|
||||||
|
if (State == EAnchorLocationState::NoAnchor)
|
||||||
|
{
|
||||||
|
// Reads the way the anchor row above it does, because both are answering about the same missing
|
||||||
|
// thing: an action with no anchor has no record to report.
|
||||||
|
return LOCTEXT("AnchorLocationNone", "None");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State == EAnchorLocationState::NotRecorded)
|
||||||
|
{
|
||||||
|
// Said out loud rather than left blank, because it is not an empty row: it is an action that
|
||||||
|
// will have nowhere to send its NPC in a world that cannot see the anchor, and this is the only
|
||||||
|
// place that shows one.
|
||||||
|
return LOCTEXT("AnchorLocationNotRecorded", "Not recorded");
|
||||||
|
}
|
||||||
|
|
||||||
|
// From here on a place was recorded, so the recorded point is what the row has to say.
|
||||||
|
const FText RecordedLocation = FText::FromString(Snapshot->GetLocation().ToCompactString());
|
||||||
|
|
||||||
|
if (State == EAnchorLocationState::Unresolved)
|
||||||
|
{
|
||||||
|
// The record still stands and is still shown, because it is the only thing anybody knows about
|
||||||
|
// where that anchor is: a player's copy of this level with that furniture's cell streamed out is
|
||||||
|
// precisely the world that will read it. Marked so that nobody takes it for confirmed.
|
||||||
|
return FText::Format(
|
||||||
|
LOCTEXT("AnchorLocationUnresolved", "{0} (the anchor is not in this level)"),
|
||||||
|
RecordedLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State == EAnchorLocationState::Differs)
|
||||||
|
{
|
||||||
|
// The record is not the wrong value, it is what the anchor used to be: the anchor is what has
|
||||||
|
// moved since, so the marker names the disagreement rather than blaming either side of it.
|
||||||
|
return FText::Format(
|
||||||
|
LOCTEXT("AnchorLocationDiffers", "{0} (differs)"),
|
||||||
|
RecordedLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RecordedLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
FText FNpcScheduleActionCustomization::GetAnchorLocationTooltip(TSharedRef<IPropertyHandle> AnchorHandle) const
|
||||||
|
{
|
||||||
|
TOptional<FTransform> Snapshot;
|
||||||
|
FTransform LiveTransform;
|
||||||
|
const EAnchorLocationState State = GetAnchorLocationState(AnchorHandle, Snapshot, LiveTransform);
|
||||||
|
|
||||||
|
// The same opening in every state, so that the row explains itself before it says anything about
|
||||||
|
// this action, and says once and for all why there is nothing on it to edit.
|
||||||
|
const FText Lead = LOCTEXT("AnchorLocationTooltipLead", "Where this action sends the NPC while its anchor sits in a cell nobody has loaded, recorded by picking the anchor rather than typed in, and kept in the action itself so that the schedule still means something in a world that cannot see the furniture.");
|
||||||
|
|
||||||
|
if (State == EAnchorLocationState::NoAnchor)
|
||||||
|
{
|
||||||
|
return FText::Format(
|
||||||
|
LOCTEXT("AnchorLocationTooltipNone", "{0}\nThis action has no anchor, so it has no recorded place: the NPC happens wherever it already is."),
|
||||||
|
Lead);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State == EAnchorLocationState::NotRecorded)
|
||||||
|
{
|
||||||
|
// Where the anchor stands right now is named when this level can answer for it, since that is
|
||||||
|
// the place the action uses in the editor and the closest thing to the half that was never
|
||||||
|
// written down. Both ways of writing it are named as well, because both are one menu away.
|
||||||
|
FTransform CurrentTransform;
|
||||||
|
if (ResolveAnchorSnapshot(ReadAnchorGuid(AnchorHandle), CurrentTransform))
|
||||||
|
{
|
||||||
|
return FText::Format(
|
||||||
|
LOCTEXT("AnchorLocationTooltipNotRecorded", "{0}\nNo place has been recorded for this anchor yet, so a world that cannot see it has nowhere to send the NPC. The anchor stands at {1} now: pick it again to record that, or run Recheck Schedules to record a place for every anchor this level can answer for."),
|
||||||
|
Lead,
|
||||||
|
FText::FromString(CurrentTransform.GetLocation().ToCompactString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return FText::Format(
|
||||||
|
LOCTEXT("AnchorLocationTooltipNotRecordedUnresolved", "{0}\nNo place has been recorded for this anchor yet, and no actor in this level claims its id either, so nothing here can say where the NPC would be sent. Recheck Schedules records a place for every anchor this level can answer for."),
|
||||||
|
Lead);
|
||||||
|
}
|
||||||
|
|
||||||
|
// What was recorded, as the whole transform it is: which way the anchor faces is part of the place,
|
||||||
|
// which is why the marker also counts a turn as a difference.
|
||||||
|
const FText Recorded = FText::Format(
|
||||||
|
LOCTEXT("AnchorLocationTooltipRecorded", "Recorded at {0}, facing {1}."),
|
||||||
|
FText::FromString(Snapshot->GetLocation().ToCompactString()),
|
||||||
|
FText::FromString(Snapshot->Rotator().ToCompactString()));
|
||||||
|
|
||||||
|
if (State == EAnchorLocationState::Unresolved)
|
||||||
|
{
|
||||||
|
return FText::Format(
|
||||||
|
LOCTEXT("AnchorLocationTooltipUnresolved", "{0}\n{1}\nNothing in this level claims the anchor's id, so the recorded place cannot be checked against it. That is either furniture sitting in a cell that is not loaded or furniture that is gone, and nothing here can tell those two apart, so the record is left exactly as it is."),
|
||||||
|
Lead,
|
||||||
|
Recorded);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State == EAnchorLocationState::Differs)
|
||||||
|
{
|
||||||
|
return FText::Format(
|
||||||
|
LOCTEXT("AnchorLocationTooltipDiffers", "{0}\n{1}\nThe anchor now stands at {2}, facing {3}, so this action would send the NPC to where the furniture used to be. A turn counts as a move here, the same way it does for Recheck Schedules: an NPC sent to an old rotation sleeps the wrong way round.\nRun Recheck Schedules to bring every recorded place in the project in line with this level, or pick this anchor again to record just this one."),
|
||||||
|
Lead,
|
||||||
|
Recorded,
|
||||||
|
FText::FromString(LiveTransform.GetLocation().ToCompactString()),
|
||||||
|
FText::FromString(LiveTransform.Rotator().ToCompactString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up to date, so there is nothing to ask of the author and nothing more to say than what the record
|
||||||
|
// is: the row stays quiet about the state it is happy with rather than reporting it as a result.
|
||||||
|
return FText::Format(
|
||||||
|
LOCTEXT("AnchorLocationTooltipUpToDate", "{0}\n{1}"),
|
||||||
|
Lead,
|
||||||
|
Recorded);
|
||||||
|
}
|
||||||
|
|
||||||
|
FSlateColor FNpcScheduleActionCustomization::GetAnchorLocationColor(TSharedRef<IPropertyHandle> AnchorHandle) const
|
||||||
|
{
|
||||||
|
TOptional<FTransform> Snapshot;
|
||||||
|
FTransform LiveTransform;
|
||||||
|
const EAnchorLocationState State = GetAnchorLocationState(AnchorHandle, Snapshot, LiveTransform);
|
||||||
|
|
||||||
|
if (State == EAnchorLocationState::Differs)
|
||||||
|
{
|
||||||
|
// The theme's own warning, which is what a drifted schedule reads as wherever else the drift is
|
||||||
|
// reported, so the two ways of being told about the same thing look like the same thing.
|
||||||
|
// Deliberately not the error colour: a record that has gone out of date is authored data that
|
||||||
|
// needs updating, not a broken asset, and nothing about the schedule is lost until it is run in
|
||||||
|
// a world that cannot see the anchor.
|
||||||
|
return FAppStyle::Get().GetSlateColor(TEXT("Colors.Warning"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State == EAnchorLocationState::NoAnchor || State == EAnchorLocationState::NotRecorded)
|
||||||
|
{
|
||||||
|
// Subdued while there is no record to report, the way the anchor row above is subdued while
|
||||||
|
// there is no anchor, so the two rows read as one answer about one thing rather than one of them
|
||||||
|
// shouting and the other whispering.
|
||||||
|
return FSlateColor::UseSubduedForeground();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A recorded place, whether or not this level can check it: an action meant for a cell nobody has
|
||||||
|
// loaded is doing exactly what the record is for, and should not look like something to fix.
|
||||||
|
return FSlateColor::UseForeground();
|
||||||
|
}
|
||||||
|
|
||||||
bool FNpcScheduleActionCustomization::CanClearAnchor(TSharedRef<IPropertyHandle> AnchorHandle) const
|
bool FNpcScheduleActionCustomization::CanClearAnchor(TSharedRef<IPropertyHandle> AnchorHandle) const
|
||||||
{
|
{
|
||||||
return ReadAnchorGuid(AnchorHandle).IsValid();
|
return ReadAnchorGuid(AnchorHandle).IsValid();
|
||||||
@@ -272,6 +643,28 @@ void FNpcScheduleActionCustomization::AssignAnchor(TSharedRef<IPropertyHandle> A
|
|||||||
// invalid id is written the same way, and that is what clearing the anchor is.
|
// invalid id is written the same way, and that is what clearing the anchor is.
|
||||||
AnchorHandle->SetValueFromFormattedString(NewAnchorGuid.ToString(EGuidFormats::Digits));
|
AnchorHandle->SetValueFromFormattedString(NewAnchorGuid.ToString(EGuidFormats::Digits));
|
||||||
|
|
||||||
|
// The place that goes with the id, worked out from the id and stored beside it: an id on its own
|
||||||
|
// leaves an NPC with somewhere to walk to only once somebody has seen the anchor, and a place on
|
||||||
|
// its own would never be reached at all. Both are written inside the one transaction above, so that
|
||||||
|
// undo puts the pair back together rather than leaving one of them behind. A snapshot is written
|
||||||
|
// even when there is nowhere to be, which is what keeps a stale one from outliving the anchor it
|
||||||
|
// belonged to.
|
||||||
|
FTransform Snapshot = FTransform::Identity;
|
||||||
|
const bool bHasSnapshot = ResolveAnchorSnapshot(NewAnchorGuid, Snapshot);
|
||||||
|
|
||||||
|
// A field that is not there is not a failure, it is an action that is still assigned and simply has
|
||||||
|
// no place recorded, which is the state of every schedule authored before this existed.
|
||||||
|
if (const TSharedPtr<IPropertyHandle> AnchorTransformHandle = GetAnchorSibling(AnchorHandle, AnchorTransformPropertyName))
|
||||||
|
{
|
||||||
|
WriteCachedAnchorTransform(AnchorTransformHandle.ToSharedRef(), Snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetValue rather than a raw write, because the typed setter raises the change notification itself.
|
||||||
|
if (const TSharedPtr<IPropertyHandle> HasAnchorTransformHandle = GetAnchorSibling(AnchorHandle, HasAnchorTransformPropertyName))
|
||||||
|
{
|
||||||
|
HasAnchorTransformHandle->SetValue(bHasSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
for (UObject* Outer : OuterObjects)
|
for (UObject* Outer : OuterObjects)
|
||||||
{
|
{
|
||||||
if (Outer != nullptr)
|
if (Outer != nullptr)
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
#include "Framework/Docking/TabManager.h"
|
#include "Framework/Docking/TabManager.h"
|
||||||
#include "Modules/ModuleManager.h"
|
#include "Modules/ModuleManager.h"
|
||||||
#include "PropertyEditorModule.h"
|
#include "PropertyEditorModule.h"
|
||||||
|
#include "Styling/AppStyle.h"
|
||||||
#include "ToolMenus.h"
|
#include "ToolMenus.h"
|
||||||
#include "Widgets/Docking/SDockTab.h"
|
#include "Widgets/Docking/SDockTab.h"
|
||||||
|
|
||||||
#include "ItemDataAssetCustomization.h"
|
#include "ItemDataAssetCustomization.h"
|
||||||
#include "ItemEditorWindow.h"
|
#include "ItemEditorWindow.h"
|
||||||
#include "NpcScheduleActionCustomization.h"
|
#include "NpcScheduleActionCustomization.h"
|
||||||
|
#include "ScheduleAssetRecheck.h"
|
||||||
|
|
||||||
#define LOCTEXT_NAMESPACE "ProjectEleriEditorModule"
|
#define LOCTEXT_NAMESPACE "ProjectEleriEditorModule"
|
||||||
|
|
||||||
@@ -88,6 +90,14 @@ void FProjectEleriEditorModule::OpenItemEditorTab()
|
|||||||
FGlobalTabmanager::Get()->TryInvokeTab(ItemEditorTabName);
|
FGlobalTabmanager::Get()->TryInvokeTab(ItemEditorTabName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void FProjectEleriEditorModule::RecheckScheduleAssets()
|
||||||
|
{
|
||||||
|
// Thin on purpose: the sweep reports what it found, in a notification and in the log, so all that
|
||||||
|
// is left for the module is to give it somewhere to be reached from. Whatever it was not able to
|
||||||
|
// answer for is part of that report rather than something to be decided again here.
|
||||||
|
ScheduleAssetRecheck::RecheckAllScheduleAssets();
|
||||||
|
}
|
||||||
|
|
||||||
void FProjectEleriEditorModule::RegisterMenus()
|
void FProjectEleriEditorModule::RegisterMenus()
|
||||||
{
|
{
|
||||||
FToolMenuOwnerScoped OwnerScoped(this);
|
FToolMenuOwnerScoped OwnerScoped(this);
|
||||||
@@ -108,6 +118,41 @@ void FProjectEleriEditorModule::RegisterMenus()
|
|||||||
FUIAction(FExecuteAction::CreateRaw(this, &FProjectEleriEditorModule::OpenItemEditorTab))
|
FUIAction(FExecuteAction::CreateRaw(this, &FProjectEleriEditorModule::OpenItemEditorTab))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Tools menu of the level editor, in a section of this module's own so that the entries this
|
||||||
|
// module adds are gathered in one place instead of being scattered among the engine's tools.
|
||||||
|
if (UToolMenu* ToolsMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Tools"))
|
||||||
|
{
|
||||||
|
FToolMenuSection& Section = ToolsMenu->AddSection(
|
||||||
|
TEXT("ScheduleAssets"),
|
||||||
|
LOCTEXT("ScheduleAssetsSectionLabel", "Eleri Tools"),
|
||||||
|
FToolMenuInsert(NAME_None, EToolMenuInsertType::First)
|
||||||
|
);
|
||||||
|
|
||||||
|
Section.AddMenuEntry(
|
||||||
|
TEXT("RecheckScheduleAssets"),
|
||||||
|
LOCTEXT("RecheckScheduleAssetsMenuLabel", "Recheck Schedule Assets"),
|
||||||
|
LOCTEXT("RecheckScheduleAssetsTooltip", "Compare every NPC schedule asset's recorded anchor positions with the anchors standing in this level, and save the assets that have moved."),
|
||||||
|
FSlateIcon(),
|
||||||
|
FUIAction(FExecuteAction::CreateRaw(this, &FProjectEleriEditorModule::RecheckScheduleAssets))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The level editor toolbar keeps a stretch of itself clear for plugins that want a button there, so
|
||||||
|
// this adds to a slot that already exists rather than registering a toolbar of its own that would
|
||||||
|
// then have to be found a place on screen.
|
||||||
|
if (UToolMenu* ToolbarMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.LevelEditorToolBar.User"))
|
||||||
|
{
|
||||||
|
FToolMenuSection& Section = ToolbarMenu->FindOrAddSection(TEXT("ScheduleAssets"));
|
||||||
|
|
||||||
|
Section.AddEntry(FToolMenuEntry::InitToolBarButton(
|
||||||
|
TEXT("RecheckScheduleAssets"),
|
||||||
|
FUIAction(FExecuteAction::CreateRaw(this, &FProjectEleriEditorModule::RecheckScheduleAssets)),
|
||||||
|
LOCTEXT("RecheckScheduleAssetsToolbarLabel", "Recheck Schedules"),
|
||||||
|
LOCTEXT("RecheckScheduleAssetsTooltip", "Compare every NPC schedule asset's recorded anchor positions with the anchors standing in this level, and save the assets that have moved."),
|
||||||
|
FSlateIcon(FAppStyle::GetAppStyleSetName(), TEXT("Icons.Adjust"))
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#undef LOCTEXT_NAMESPACE
|
#undef LOCTEXT_NAMESPACE
|
||||||
|
|||||||
266
Source/ProjectEleriEditor/Private/ScheduleAssetRecheck.cpp
Normal file
266
Source/ProjectEleriEditor/Private/ScheduleAssetRecheck.cpp
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
// Fill out your copyright notice in the Description page of Project Settings.
|
||||||
|
|
||||||
|
#include "ScheduleAssetRecheck.h"
|
||||||
|
|
||||||
|
#include "AssetRegistry/AssetRegistryModule.h"
|
||||||
|
#include "AssetRegistry/IAssetRegistry.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
#include "EngineUtils.h"
|
||||||
|
#include "FileHelpers.h"
|
||||||
|
#include "Framework/Notifications/NotificationManager.h"
|
||||||
|
#include "Modules/ModuleManager.h"
|
||||||
|
#include "Widgets/Notifications/SNotificationList.h"
|
||||||
|
|
||||||
|
#include "ProjectEleri/Ai/NpcScheduleAsset.h"
|
||||||
|
#include "ProjectEleri/GameObjects/EleriStandPoint.h"
|
||||||
|
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
|
||||||
|
|
||||||
|
#define LOCTEXT_NAMESPACE "ScheduleAssetRecheck"
|
||||||
|
|
||||||
|
namespace ScheduleAssetRecheck
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
/** The id a schedule would name this actor by. 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, so this looks for anchors exactly the way the game and the details panel do rather
|
||||||
|
* than through a notion of furniture of its own.
|
||||||
|
*
|
||||||
|
* Written out rather than shared with FNpcScheduleActionCustomization, which answers the same
|
||||||
|
* question about the details panel's selection and keeps the answer to itself. It is one line
|
||||||
|
* of casting, and duplicating it is the smaller fault next to a picker that only ever sees one
|
||||||
|
* action at a time having to grow a second responsibility. */
|
||||||
|
FGuid GetAnchorId(AActor* Actor)
|
||||||
|
{
|
||||||
|
if (Actor == nullptr)
|
||||||
|
{
|
||||||
|
return FGuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
const IActorPersistentIdInterface* Identified = Cast<IActorPersistentIdInterface>(Actor);
|
||||||
|
return Identified != nullptr ? Identified->GetPersistentId() : FGuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every anchor the open level can answer for, by the id a schedule names it with.
|
||||||
|
*
|
||||||
|
* One walk for the whole sweep rather than a walk per action: the levels this runs on hold a
|
||||||
|
* project's worth of schedules, and asking the level once per action would make the sweep
|
||||||
|
* quadratic in the size of the level for no reason, since the answer cannot change under it.
|
||||||
|
*
|
||||||
|
* The first actor to claim an id keeps it, which is the same answer a game arrives at, both
|
||||||
|
* sides walking the level and taking what they meet first. A second claimant is logged while
|
||||||
|
* somebody can still do something about it, because a schedule naming that id is going to
|
||||||
|
* follow one of the two and neither of them gets a say in which. */
|
||||||
|
void CollectAnchorsById(UWorld& World, TMap<FGuid, AActor*>& OutAnchorsById)
|
||||||
|
{
|
||||||
|
for (TActorIterator<AActor> It(&World); It; ++It)
|
||||||
|
{
|
||||||
|
AActor* Actor = *It;
|
||||||
|
const FGuid AnchorId = GetAnchorId(Actor);
|
||||||
|
if (!AnchorId.IsValid())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
AActor*& Claimant = OutAnchorsById.FindOrAdd(AnchorId);
|
||||||
|
if (Claimant == nullptr)
|
||||||
|
{
|
||||||
|
Claimant = Actor;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("NPC schedule anchor %s is claimed by both %s and %s, and a schedule naming it will follow %s."),
|
||||||
|
*AnchorId.ToString(EGuidFormats::Digits), *Claimant->GetName(), *Actor->GetName(), *Claimant->GetName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FResult RecheckAllScheduleAssets()
|
||||||
|
{
|
||||||
|
FResult Result;
|
||||||
|
|
||||||
|
// One place that says how it went, so that the answer reads the same whether the sweep found
|
||||||
|
// nothing to do, did its job, or could not finish it.
|
||||||
|
const auto Notify = [](const FText& Summary, const FText& Detail, SNotificationItem::ECompletionState State)
|
||||||
|
{
|
||||||
|
FNotificationInfo Info(Summary);
|
||||||
|
if (!Detail.IsEmpty())
|
||||||
|
{
|
||||||
|
Info.SubText = Detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
Info.bUseSuccessFailIcons = true;
|
||||||
|
Info.ExpireDuration = 5.0f;
|
||||||
|
|
||||||
|
if (TSharedPtr<SNotificationItem> Item = FSlateNotificationManager::Get().AddNotification(Info))
|
||||||
|
{
|
||||||
|
Item->SetCompletionState(State);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The editor world is the only one this can read, both because it is the only one there is
|
||||||
|
// while nothing is playing and because what is being compared is authored data against the
|
||||||
|
// level it was authored in. With no level there is nothing to compare against at all, and
|
||||||
|
// writing the schedules from nowhere would be inventing a place rather than recording one.
|
||||||
|
UWorld* World = GEditor != nullptr ? GEditor->GetEditorWorldContext().World() : nullptr;
|
||||||
|
if (World == nullptr)
|
||||||
|
{
|
||||||
|
Notify(LOCTEXT("NoEditorWorld", "Recheck Schedule Assets: no level is open."), FText(), SNotificationItem::CS_Fail);
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
TMap<FGuid, AActor*> AnchorsById;
|
||||||
|
CollectAnchorsById(*World, AnchorsById);
|
||||||
|
|
||||||
|
IAssetRegistry& AssetRegistry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get();
|
||||||
|
|
||||||
|
TArray<FAssetData> ScheduleAssets;
|
||||||
|
AssetRegistry.GetAssetsByClass(UNpcScheduleAsset::StaticClass()->GetClassPathName(), ScheduleAssets, /*bSearchSubClasses=*/true);
|
||||||
|
|
||||||
|
// Only the packages that were actually written are gathered, so that a sweep which found
|
||||||
|
// nothing to do leaves the disk alone, and one that found several saves them in a single step
|
||||||
|
// rather than one stamp of the clock per asset.
|
||||||
|
TArray<UPackage*> PackagesToSave;
|
||||||
|
|
||||||
|
for (const FAssetData& AssetData : ScheduleAssets)
|
||||||
|
{
|
||||||
|
// The live object rather than the registry's tags, because what is being read is UPROPERTY
|
||||||
|
// data the registry does not index, and because what is being written has to go through the
|
||||||
|
// object the registry would otherwise be left holding a stale idea of.
|
||||||
|
UNpcScheduleAsset* Schedule = Cast<UNpcScheduleAsset>(AssetData.GetAsset());
|
||||||
|
if (Schedule == nullptr)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
++Result.NumAssetsScanned;
|
||||||
|
|
||||||
|
bool bScheduleChanged = false;
|
||||||
|
|
||||||
|
for (FScheduledAction& Action : Schedule->ScheduledActions)
|
||||||
|
{
|
||||||
|
// An action with no anchor happens wherever the NPC already is. There is no anchor for
|
||||||
|
// it to have moved away from, so there is nothing here that can be stale.
|
||||||
|
if (!Action.UsableObjectGuid.IsValid())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
++Result.NumAnchorsChecked;
|
||||||
|
|
||||||
|
AActor* const* Anchor = AnchorsById.Find(Action.UsableObjectGuid);
|
||||||
|
if (Anchor == nullptr)
|
||||||
|
{
|
||||||
|
// Not in this level: either the cell the furniture lives in is not loaded, or the
|
||||||
|
// schedule names something that is gone, and nothing here can tell those apart. The
|
||||||
|
// copy is left exactly as it was, the same way the details panel leaves it alone
|
||||||
|
// when it cannot find an anchor: it is the only thing anybody knows about where
|
||||||
|
// that anchor is, and this sweep has nothing better to put in its place.
|
||||||
|
++Result.NumAnchorsUnresolved;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FTransform AnchorTransform = EleriStandPoint::ResolveAnchorTransform(*Anchor);
|
||||||
|
|
||||||
|
// The whole transform rather than the point on its own, because which way an anchor
|
||||||
|
// faces is part of where an NPC is put: a bed turned around is still walked to and
|
||||||
|
// still slept in, with the NPC doing it facing the wall. Comparing what the details
|
||||||
|
// panel would have written is also what makes a schedule this agrees with one the
|
||||||
|
// panel has nothing left to say about.
|
||||||
|
if (Action.bHasAnchorTransform && Action.AnchorTransform.Equals(AnchorTransform))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Action.AnchorTransform = AnchorTransform;
|
||||||
|
Action.bHasAnchorTransform = true;
|
||||||
|
++Result.NumAnchorsUpdated;
|
||||||
|
bScheduleChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bScheduleChanged)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notified and dirtied the way the details panel does it, so that an asset open in another
|
||||||
|
// tab follows the write instead of sitting there showing the transform that has just been
|
||||||
|
// replaced, and so that anything else watching the asset sees the change.
|
||||||
|
Schedule->PostEditChange();
|
||||||
|
Schedule->MarkPackageDirty();
|
||||||
|
|
||||||
|
if (UPackage* Package = Schedule->GetPackage())
|
||||||
|
{
|
||||||
|
PackagesToSave.Add(Package);
|
||||||
|
++Result.NumAssetsUpdated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One save for the whole sweep, over the packages that were written and no others. A false
|
||||||
|
// answer here is the one outcome that has to be said out loud rather than logged: the assets
|
||||||
|
// are then changed in memory and not on disk, and an author who takes this for a save loses
|
||||||
|
// the whole of it on the next reload.
|
||||||
|
const bool bSaved = PackagesToSave.Num() == 0
|
||||||
|
|| UEditorLoadingAndSavingUtils::SavePackages(PackagesToSave, /*bOnlyDirty=*/true);
|
||||||
|
|
||||||
|
UE_LOG(LogTemp, Log,
|
||||||
|
TEXT("Recheck Schedule Assets: %d asset(s) scanned, %d saved, %d anchor(s) checked, %d refreshed, %d not in this level."),
|
||||||
|
Result.NumAssetsScanned, Result.NumAssetsUpdated, Result.NumAnchorsChecked, Result.NumAnchorsUpdated, Result.NumAnchorsUnresolved);
|
||||||
|
|
||||||
|
// Said out loud rather than folded into the counts, because it is a question rather than an
|
||||||
|
// answer: an id nobody claims is either furniture in a cell that is not loaded, or a schedule
|
||||||
|
// naming something that no longer exists, and only the author can say which of the two it was
|
||||||
|
// meant to be. It goes beside every outcome below, since it is true of all of them.
|
||||||
|
const FText UnresolvedNote = Result.NumAnchorsUnresolved == 0
|
||||||
|
? FText()
|
||||||
|
: FText::Format(
|
||||||
|
LOCTEXT("UnresolvedAnchors", "{0} anchor(s) are not in this level, and their recorded places were left alone."),
|
||||||
|
FText::AsNumber(Result.NumAnchorsUnresolved));
|
||||||
|
|
||||||
|
// No assets at all is not a sweep that found everything clean, it is a sweep that looked in the
|
||||||
|
// wrong place, and it must not read as a pass.
|
||||||
|
if (Result.NumAssetsScanned == 0)
|
||||||
|
{
|
||||||
|
Notify(LOCTEXT("NoScheduleAssets", "Recheck Schedule Assets: no NPC schedule assets were found."), FText(), SNotificationItem::CS_None);
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bSaved)
|
||||||
|
{
|
||||||
|
Notify(
|
||||||
|
FText::Format(
|
||||||
|
LOCTEXT("SaveFailed", "Recheck Schedule Assets: {0} asset(s) were updated but could not be saved."),
|
||||||
|
FText::AsNumber(Result.NumAssetsUpdated)),
|
||||||
|
UnresolvedNote,
|
||||||
|
SNotificationItem::CS_Fail);
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Result.NumAssetsUpdated == 0)
|
||||||
|
{
|
||||||
|
Notify(
|
||||||
|
FText::Format(
|
||||||
|
LOCTEXT("UpToDate", "Recheck Schedule Assets: all {0} asset(s) already match this level."),
|
||||||
|
FText::AsNumber(Result.NumAssetsScanned)),
|
||||||
|
UnresolvedNote,
|
||||||
|
SNotificationItem::CS_Success);
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notify(
|
||||||
|
FText::Format(
|
||||||
|
LOCTEXT("Updated", "Recheck Schedule Assets: saved {0} of {1} asset(s), {2} anchor(s) refreshed."),
|
||||||
|
FText::AsNumber(Result.NumAssetsUpdated),
|
||||||
|
FText::AsNumber(Result.NumAssetsScanned),
|
||||||
|
FText::AsNumber(Result.NumAnchorsUpdated)),
|
||||||
|
UnresolvedNote,
|
||||||
|
SNotificationItem::CS_Success);
|
||||||
|
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#undef LOCTEXT_NAMESPACE
|
||||||
|
|
||||||
@@ -22,6 +22,20 @@ class SWidget;
|
|||||||
* an id that no actor in this level claims is left as it is, because that is a real state (furniture
|
* 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.
|
* that was deleted, or furniture sitting in a sublevel that is not loaded) and not something to hide.
|
||||||
*
|
*
|
||||||
|
* Picking an anchor also records where that anchor stands, into the action's own AnchorTransform, so
|
||||||
|
* that a schedule still means something in a running world while the furniture it points at sits in a
|
||||||
|
* cell nobody has loaded. The panel is the only moment at which both halves can be had from the actor
|
||||||
|
* itself, which is why the two fields that snapshot is stored in are written here and, being written
|
||||||
|
* rather than authored, keep no row anything could be typed into.
|
||||||
|
*
|
||||||
|
* What that record needs is not another field to fill in but a way to see that it has gone stale, so
|
||||||
|
* the panel reports it in a read-only row of its own, Anchor Location, directly under the anchor it
|
||||||
|
* belongs to. The row stands the recorded place against where the anchor stands now and says so when
|
||||||
|
* the two have come apart. It reads the same whole-transform rule as Schedule Asset Recheck, so the
|
||||||
|
* row and the sweep can never disagree about which schedules have drifted, and it is the sweep - or
|
||||||
|
* picking the anchor again - that clears the marker. The two fields themselves are still reported
|
||||||
|
* through the anchor row's tooltip.
|
||||||
|
*
|
||||||
* Every other member of the struct keeps the row the details panel would have given it.
|
* Every other member of the struct keeps the row the details panel would have given it.
|
||||||
*/
|
*/
|
||||||
class FNpcScheduleActionCustomization : public IPropertyTypeCustomization
|
class FNpcScheduleActionCustomization : public IPropertyTypeCustomization
|
||||||
@@ -39,6 +53,13 @@ protected:
|
|||||||
* unassign button. Stands in for the raw Guid field, which is never shown. */
|
* unassign button. Stands in for the raw Guid field, which is never shown. */
|
||||||
void AddAnchorRow(IDetailChildrenBuilder& ChildBuilder, TSharedRef<IPropertyHandle> AnchorHandle) const;
|
void AddAnchorRow(IDetailChildrenBuilder& ChildBuilder, TSharedRef<IPropertyHandle> AnchorHandle) const;
|
||||||
|
|
||||||
|
/** The anchor location row: the place this action kept for its anchor, read back out of the
|
||||||
|
* snapshot the pick wrote and stood against where that anchor stands now, directly under the anchor
|
||||||
|
* row it belongs to. Read only on purpose rather than a disabled copy of the rows above it: the
|
||||||
|
* record is written by picking an anchor and by Recheck Schedules, and a row that let it be typed
|
||||||
|
* in would be a third writer able to send an NPC anywhere at all. */
|
||||||
|
void AddAnchorLocationRow(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
|
/** 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. */
|
* actor in this level claims it. */
|
||||||
FText GetAnchorText(TSharedRef<IPropertyHandle> AnchorHandle) const;
|
FText GetAnchorText(TSharedRef<IPropertyHandle> AnchorHandle) const;
|
||||||
@@ -51,6 +72,48 @@ protected:
|
|||||||
* glance from one whose anchor the panel cannot name. */
|
* glance from one whose anchor the panel cannot name. */
|
||||||
FSlateColor GetAnchorColor(TSharedRef<IPropertyHandle> AnchorHandle) const;
|
FSlateColor GetAnchorColor(TSharedRef<IPropertyHandle> AnchorHandle) const;
|
||||||
|
|
||||||
|
/** The recorded place, in whichever state the row is actually in: "None" for an action with no
|
||||||
|
* anchor, "Not recorded" for an anchor picked before there was anywhere to record one, and the
|
||||||
|
* recorded point itself, marked "(differs)" once the anchor has moved away from it. */
|
||||||
|
FText GetAnchorLocationText(TSharedRef<IPropertyHandle> AnchorHandle) const;
|
||||||
|
|
||||||
|
/** What the row is reporting and what to do when it reports a difference. Written per state, the
|
||||||
|
* way the anchor row's tooltip is, so that an action agreeing with its anchor is not handed
|
||||||
|
* instructions it has no use for. */
|
||||||
|
FText GetAnchorLocationTooltip(TSharedRef<IPropertyHandle> AnchorHandle) const;
|
||||||
|
|
||||||
|
/** Warning while the record and the anchor disagree, subdued while there is no record at all, and
|
||||||
|
* ordinary foreground otherwise - a record this level cannot check included, since a schedule for
|
||||||
|
* a cell nobody has loaded is exactly what the record is for. */
|
||||||
|
FSlateColor GetAnchorLocationColor(TSharedRef<IPropertyHandle> AnchorHandle) const;
|
||||||
|
|
||||||
|
/** How the recorded place stands against the anchor itself. One answer worked out in one place,
|
||||||
|
* because three of the members above are each asked for it: a row whose text said "(differs)"
|
||||||
|
* while its colour said otherwise would be two readings of one rule, and the rule itself is
|
||||||
|
* already shared with Schedule Asset Recheck by hand. */
|
||||||
|
enum class EAnchorLocationState : uint8
|
||||||
|
{
|
||||||
|
/** No anchor at all, so there is no place to record and nothing to compare. */
|
||||||
|
NoAnchor,
|
||||||
|
|
||||||
|
/** An anchor, but no place recorded for it: a pick made before there was anywhere to store one,
|
||||||
|
* which is the state every action authored before snapshots existed is in. */
|
||||||
|
NotRecorded,
|
||||||
|
|
||||||
|
/** A place recorded, and no actor in this level claims the anchor's id to check it against. */
|
||||||
|
Unresolved,
|
||||||
|
|
||||||
|
/** A place recorded that still matches where the anchor stands. */
|
||||||
|
UpToDate,
|
||||||
|
|
||||||
|
/** A place recorded that the anchor has since moved away from, whole transform and all. */
|
||||||
|
Differs
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Which of those states this action is in, with the two transforms it was decided from handed back
|
||||||
|
* as well, since every caller has something to say about one or both of them. */
|
||||||
|
EAnchorLocationState GetAnchorLocationState(TSharedRef<IPropertyHandle> AnchorHandle, TOptional<FTransform>& OutSnapshot, FTransform& OutLiveTransform) const;
|
||||||
|
|
||||||
bool CanClearAnchor(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
|
/** Writes one id into the action. An invalid id is what unassign does, so the button that clears
|
||||||
@@ -66,6 +129,32 @@ protected:
|
|||||||
* value and so comes back as unassigned. */
|
* value and so comes back as unassigned. */
|
||||||
static FGuid ReadAnchorGuid(const TSharedRef<IPropertyHandle>& AnchorHandle);
|
static FGuid ReadAnchorGuid(const TSharedRef<IPropertyHandle>& AnchorHandle);
|
||||||
|
|
||||||
|
/** A field of the action the anchor handle belongs to, reached from the anchor handle rather than
|
||||||
|
* handed around: the widgets that hold the anchor handle outlive the layout call that built them,
|
||||||
|
* and a sibling looked up at the moment it is written is exactly as fresh as the anchor itself.
|
||||||
|
* Null when the handle is not a child of a struct node, or when the struct has no such field, and
|
||||||
|
* that is not a failure: the anchor is then assigned without a snapshot, which is the state every
|
||||||
|
* schedule authored before snapshots existed is in. */
|
||||||
|
static TSharedPtr<IPropertyHandle> GetAnchorSibling(const TSharedRef<IPropertyHandle>& AnchorHandle, FName ChildName);
|
||||||
|
|
||||||
|
/** Where the actor an id belongs to is standing, answered through the rule the game reads
|
||||||
|
* (EleriStandPoint::ResolveAnchorTransform) rather than through the actor's own transform, so that
|
||||||
|
* a schedule saved in the editor and one resolved in a running world cannot put the same NPC in
|
||||||
|
* two different places. False when no actor in this level claims the id, which is what clearing
|
||||||
|
* the anchor leaves behind and what assigning an anchor whose sublevel is not loaded leaves
|
||||||
|
* behind. */
|
||||||
|
static bool ResolveAnchorSnapshot(const FGuid& AnchorGuid, FTransform& OutTransform);
|
||||||
|
|
||||||
|
/** The place this action has kept for its anchor, if it kept one. Nothing when no snapshot was ever
|
||||||
|
* stored, when those fields are not part of the struct, or when the row stands for several actions
|
||||||
|
* at once, since several actions have no one place between them to report. */
|
||||||
|
TOptional<FTransform> GetCachedAnchorTransform(const TSharedRef<IPropertyHandle>& AnchorHandle) const;
|
||||||
|
|
||||||
|
/** Writes the snapshot that goes with an id. Raw data rather than a formatted string, because a
|
||||||
|
* transform has no text import to round trip through and this is the only way one reaches the
|
||||||
|
* asset. */
|
||||||
|
static void WriteCachedAnchorTransform(const TSharedRef<IPropertyHandle>& AnchorTransformHandle, const FTransform& Snapshot);
|
||||||
|
|
||||||
/** The first actor in the current selection that can be an anchor, or null when there is none. */
|
/** The first actor in the current selection that can be an anchor, or null when there is none. */
|
||||||
static AActor* GetSelectedAnchorActor();
|
static AActor* GetSelectedAnchorActor();
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ public:
|
|||||||
/** Opens (or focuses) the Item Editor window. */
|
/** Opens (or focuses) the Item Editor window. */
|
||||||
void OpenItemEditorTab();
|
void OpenItemEditorTab();
|
||||||
|
|
||||||
|
/** Compares every NPC schedule asset's recorded anchor transforms against the anchors standing in
|
||||||
|
* the open level, writes the ones that have moved and saves the assets they belong to, and says
|
||||||
|
* what it found. */
|
||||||
|
void RecheckScheduleAssets();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void RegisterMenus();
|
void RegisterMenus();
|
||||||
|
|
||||||
|
|||||||
59
Source/ProjectEleriEditor/Public/ScheduleAssetRecheck.h
Normal file
59
Source/ProjectEleriEditor/Public/ScheduleAssetRecheck.h
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Fill out your copyright notice in the Description page of Project Settings.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-reads the place every NPC schedule sends its NPCs to.
|
||||||
|
*
|
||||||
|
* A schedule names its anchor by PersistentId and keeps a copy of where that anchor stood when it was
|
||||||
|
* picked beside it, see FScheduledAction::AnchorTransform. That copy is what an NPC is sent at while
|
||||||
|
* its anchor sits in a cell nobody has loaded, and it is written once, when the anchor is picked in
|
||||||
|
* the details panel. The furniture it is a copy of can be moved afterwards without anybody picking
|
||||||
|
* anything again, and then the two disagree: the schedule says the bed is where it used to be, and
|
||||||
|
* the level says otherwise.
|
||||||
|
*
|
||||||
|
* This is the sweep that closes that gap. It stands every anchor the open level can answer for
|
||||||
|
* against the copy its schedule holds, writes the ones that have moved, and saves the assets that
|
||||||
|
* changed. It is an editor tool rather than something the game runs, because the drift is a fault in
|
||||||
|
* the authored data and not in what a running game does with it: nothing reads the copy while it can
|
||||||
|
* see the anchor itself, so a game never notices, and the schedule only starts lying when an NPC
|
||||||
|
* meets a level that has the anchor streamed out.
|
||||||
|
*
|
||||||
|
* The one thing it cannot answer is an anchor that is not in the open level, which is either
|
||||||
|
* furniture in a cell that is not loaded or a schedule naming something that is gone. Nothing here
|
||||||
|
* can tell those two apart, so both are counted and reported rather than guessed at, and the copies
|
||||||
|
* they belong to are left exactly as they were.
|
||||||
|
*/
|
||||||
|
namespace ScheduleAssetRecheck
|
||||||
|
{
|
||||||
|
/** What one sweep found, so that a caller can say it out loud rather than only log it. */
|
||||||
|
struct FResult
|
||||||
|
{
|
||||||
|
/** Assets the registry handed over, whether or not anything in them had moved. */
|
||||||
|
int32 NumAssetsScanned = 0;
|
||||||
|
|
||||||
|
/** Assets that no longer agreed with the level, and so were written and saved. */
|
||||||
|
int32 NumAssetsUpdated = 0;
|
||||||
|
|
||||||
|
/** Actions carrying an anchor, which is every action there is a place to check for. An action
|
||||||
|
* with no anchor happens wherever the NPC already is and has nothing to go stale. */
|
||||||
|
int32 NumAnchorsChecked = 0;
|
||||||
|
|
||||||
|
/** Copies that had moved, or that had never been recorded at all. */
|
||||||
|
int32 NumAnchorsUpdated = 0;
|
||||||
|
|
||||||
|
/** Anchors no actor in the open level claims, whose copies were left as they were. */
|
||||||
|
int32 NumAnchorsUnresolved = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Walks every NpcScheduleAsset in the project, refreshes the anchor copy of every action whose
|
||||||
|
* anchor is standing in the open level, and saves the assets that changed.
|
||||||
|
*
|
||||||
|
* The editor world is the only one read, so what this answers for is exactly what the level in
|
||||||
|
* front of the author can see, and a schedule whose anchors live in some other level is left
|
||||||
|
* alone by the same rule that leaves an unloaded cell alone. What it did is reported through a
|
||||||
|
* notification and the log, and returned here as well, for a caller that wants the numbers. */
|
||||||
|
FResult RecheckAllScheduleAssets();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user