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)

View File

@@ -3,12 +3,14 @@
#include "Framework/Docking/TabManager.h"
#include "Modules/ModuleManager.h"
#include "PropertyEditorModule.h"
#include "Styling/AppStyle.h"
#include "ToolMenus.h"
#include "Widgets/Docking/SDockTab.h"
#include "ItemDataAssetCustomization.h"
#include "ItemEditorWindow.h"
#include "NpcScheduleActionCustomization.h"
#include "ScheduleAssetRecheck.h"
#define LOCTEXT_NAMESPACE "ProjectEleriEditorModule"
@@ -88,6 +90,14 @@ void FProjectEleriEditorModule::OpenItemEditorTab()
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()
{
FToolMenuOwnerScoped OwnerScoped(this);
@@ -108,6 +118,41 @@ void FProjectEleriEditorModule::RegisterMenus()
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

View 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

View File

@@ -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
* 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.
*/
class FNpcScheduleActionCustomization : public IPropertyTypeCustomization
@@ -39,6 +53,13 @@ protected:
* unassign button. Stands in for the raw Guid field, which is never shown. */
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
* actor in this level claims it. */
FText GetAnchorText(TSharedRef<IPropertyHandle> AnchorHandle) const;
@@ -51,6 +72,48 @@ protected:
* glance from one whose anchor the panel cannot name. */
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;
/** 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. */
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. */
static AActor* GetSelectedAnchorActor();

View File

@@ -12,6 +12,11 @@ public:
/** Opens (or focuses) the Item Editor window. */
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:
void RegisterMenus();

View 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();
}