Files
ProjectEleri/Source/ProjectEleriEditor/Private/NpcScheduleActionCustomization.cpp

872 lines
37 KiB
C++

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