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