Some housekeeping and some editor tools for schedules anchors

This commit is contained in:
2026-09-20 15:50:23 +02:00
parent 5d61de068c
commit f3a428af94
15 changed files with 945 additions and 58 deletions

View File

@@ -10,10 +10,12 @@
#include "IDetailChildrenBuilder.h"
#include "Input/Reply.h"
#include "PropertyHandle.h"
#include "ProjectEleri/GameObjects/EleriStandPoint.h"
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
#include "ScopedTransaction.h"
#include "Selection.h"
#include "Styling/AppStyle.h"
#include "UObject/Class.h"
#include "UObject/UnrealType.h"
#include "Widgets/Input/SButton.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
* id rather than a value, and so the only one that cannot be typed in. Names the UPROPERTY. */
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()
@@ -69,6 +77,22 @@ void FNpcScheduleActionCustomization::CustomizeChildren(TSharedRef<IPropertyHand
if (ChildProperty != nullptr && ChildProperty->GetFName() == AnchorPropertyName)
{
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;
}
@@ -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)
{
// 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;
}
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
{
const FGuid AnchorGuid = ReadAnchorGuid(AnchorHandle);
@@ -234,6 +400,18 @@ FText FNpcScheduleActionCustomization::GetAnchorTooltip(TSharedRef<IPropertyHand
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;
}
@@ -244,6 +422,199 @@ FSlateColor FNpcScheduleActionCustomization::GetAnchorColor(TSharedRef<IProperty
: 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
{
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.
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)
{
if (Outer != nullptr)