Display for schedule data asset timeline, view only

This commit is contained in:
2026-09-20 16:43:11 +02:00
parent f3a428af94
commit 472cac79c9
5 changed files with 787 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "NpcScheduleAssetCustomization.h"
#include "DetailCategoryBuilder.h"
#include "DetailLayoutBuilder.h"
#include "DetailWidgetRow.h"
#include "NpcScheduleTimeline.h"
#include "ProjectEleri/Ai/NpcScheduleAsset.h"
#define LOCTEXT_NAMESPACE "NpcScheduleAssetCustomization"
TSharedRef<IDetailCustomization> FNpcScheduleAssetCustomization::MakeInstance() {
return MakeShareable(new FNpcScheduleAssetCustomization());
}
void FNpcScheduleAssetCustomization::CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) {
// The asset being customized. The weak pointers the panel hands over are the whole reason the strip
// holds one of its own rather than a raw pointer: an asset can be deleted while a tab showing it is
// still open, and the strip has to draw an empty day rather than a dangling one.
TArray<TWeakObjectPtr<UObject>> Objects;
DetailBuilder.GetObjectsBeingCustomized(Objects);
UNpcScheduleAsset* Schedule = Objects.Num() > 0 ? Cast<UNpcScheduleAsset>(Objects[0].Get()) : nullptr;
// A category of this module's own, sorted after the one an asset's properties land in, so that the
// strip is drawn under every row the asset already had rather than beside them. It is created even
// when nothing is being customized, so that a panel left open on a deleted asset still has a strip
// in it saying so.
IDetailCategoryBuilder& TimelineCategory = DetailBuilder.EditCategory(
TEXT("ScheduleTimeline"),
LOCTEXT("TimelineCategoryLabel", "Day Timeline"),
ECategoryPriority::Uncommon);
// The whole row, name column and value column both, because the strip is a picture of the day and
// not the value of anything: it is laid out against the width of the panel it is handed, and this
// is the slot that hands it all of it.
TimelineCategory.AddCustomRow(LOCTEXT("TimelineRowLabel", "Day Timeline"))
.WholeRowContent()
[
SNew(SNpcScheduleTimeline)
.Schedule(Schedule)
];
}
#undef LOCTEXT_NAMESPACE

View File

@@ -0,0 +1,585 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "NpcScheduleTimeline.h"
#include "Editor.h"
#include "EngineUtils.h"
#include "Rendering/DrawElements.h"
#include "Styling/CoreStyle.h"
#include "Templates/TypeHash.h"
#include "Widgets/SNullWidget.h"
#include "ProjectEleri/Ai/NpcScheduleAsset.h"
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
#define LOCTEXT_NAMESPACE "NpcScheduleTimeline"
/*-----------------------------------------------------------------------------
How the strip is laid out
-----------------------------------------------------------------------------*/
namespace
{
/** One day, in the minutes of the day that every start in a schedule is measured in. */
constexpr int32 MinutesPerDay = 24 * 60;
/** Where the strip starts and ends inside the widget, so that a slot sitting at midnight is not
* drawn against the panel's own edge and its label has somewhere to start. */
constexpr float StripLeftPadding = 8.0f;
constexpr float StripRightPadding = 8.0f;
/** The ruler row, which holds the hour labels and nothing else. */
constexpr float HourLabelHeight = 12.0f;
/** One row of slots. High enough to read a label in, and no higher, since a day is mostly one row. */
constexpr float LaneHeight = 26.0f;
constexpr float LaneGap = 3.0f;
/** The legend below the day: one line per action, each with the swatch that matches its slot. */
constexpr float LegendTopGap = 8.0f;
constexpr float LegendRowHeight = 14.0f;
constexpr float LegendSwatchWidth = 8.0f;
constexpr float LegendSwatchGap = 6.0f;
/** Below that, and under the day when there is no legend to draw. */
constexpr float BottomPadding = 2.0f;
/** What the strip asks for in a panel that has not decided how wide it should be yet. The width it
* actually gets is whatever the panel gives it, so this is only ever a starting point. */
constexpr float PreferredWidth = 520.0f;
/** A slot narrower than this is drawn as a bare block: no label fits in it, and a label spilling out
* of its own slot would read as belonging to the slot beside it. */
constexpr float MinimumLabelledSlotWidth = 68.0f;
/** A slot's own label: how far it sits from the slot's edges, and how tall the line it is drawn on is,
* which is what centres it in a lane. */
constexpr float SlotLabelInset = 6.0f;
constexpr float SlotLabelHeight = 12.0f;
/** Every this many hours the ruler carries a label. Written out rather than derived so that the
* labels line up with the times an author thinks in. */
constexpr int32 LabelledHourInterval = 3;
/** How long an anchor's name is trusted for before it is read out of the level again. The names are
* the one thing being drawn that can go stale without the schedule changing - an anchor renamed or
* deleted while this tab sat open - and re-reading them is a walk of the level, so it is done on a
* timer rather than every frame. */
constexpr double AnchorLabelRefreshPeriod = 2.0;
/** Colour of the trough the lanes sit in, of the hour marks, and of the text that is not a label of
* anything: the same three the rest of the editor's dark panels read against. */
const FLinearColor TroughColor(0.0f, 0.0f, 0.0f, 0.55f);
const FLinearColor LaneColor(1.0f, 1.0f, 1.0f, 0.03f);
const FLinearColor HourMarkColor(1.0f, 1.0f, 1.0f, 0.07f);
const FLinearColor LabelledHourMarkColor(1.0f, 1.0f, 1.0f, 0.16f);
const FLinearColor HourLabelColor(0.62f, 0.62f, 0.62f);
const FLinearColor LegendTextColor(0.80f, 0.80f, 0.80f);
const FLinearColor QuietTextColor(0.55f, 0.55f, 0.55f);
/** A slot's label is drawn dark on the slot's own colour, which is the only way one label style stays
* legible on six different backgrounds without a second colour per activity to go with them. */
const FLinearColor SlotLabelColor(0.06f, 0.06f, 0.06f, 0.90f);
/** The marker where a slot that runs past midnight carries on at the other end of the strip. Drawn on
* both halves, so that either end says which window it belongs to. */
const FLinearColor WrapMarkerColor(1.0f, 1.0f, 1.0f, 0.85f);
constexpr float WrapMarkerWidth = 3.0f;
/*-----------------------------------------------------------------------------
Reading the schedule and the level
-----------------------------------------------------------------------------*/
/** 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.
*
* Written out rather than shared with the two other copies of it, exactly as ScheduleAssetRecheck
* says of its own: it is one line of casting, and the smaller fault next to a details panel that
* answers about one action at a time having to grow a second responsibility for a view of a day. */
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 strip rather than a walk per action, which is the rule the recheck sweep
* follows and for the same reason: the answer cannot change under the walk. The first actor to claim
* an id keeps it, which is also what a running game arrives at, both sides walking the level and
* taking what they meet first. */
void CollectAnchorNames(TMap<FGuid, FString>& OutAnchorNames)
{
UWorld* World = GEditor != nullptr ? GEditor->GetEditorWorldContext().World() : nullptr;
if (World == nullptr)
{
return;
}
for (TActorIterator<AActor> It(World); It; ++It)
{
AActor* Actor = *It;
const FGuid AnchorId = GetAnchorId(Actor);
if (!AnchorId.IsValid() || OutAnchorNames.Contains(AnchorId))
{
continue;
}
OutAnchorNames.Add(AnchorId, Actor->GetActorNameOrLabel());
}
}
/** What an activity is called, which is the name the enum itself carries rather than a table here: a
* behaviour added as an enum value is then named in the strip without anyone remembering this file
* exists. */
FText GetActivityLabel(ENpcActivity Activity)
{
const UEnum* Enum = StaticEnum<ENpcActivity>();
return Enum != nullptr ? Enum->GetDisplayNameTextByValue(static_cast<int64>(Activity)) : FText::GetEmpty();
}
/** The colour that activity is drawn in. Handed out per activity rather than hashed, because these
* are the ones that exist and they are worth telling apart at a glance: sleep dark, work warm, and
* the two quiet ones grey. */
FLinearColor GetActivityColor(ENpcActivity Activity)
{
switch (Activity)
{
case ENpcActivity::Idle: return FLinearColor(0.42f, 0.44f, 0.48f);
case ENpcActivity::Sleep: return FLinearColor(0.29f, 0.36f, 0.72f);
case ENpcActivity::Sit: return FLinearColor(0.22f, 0.55f, 0.60f);
case ENpcActivity::Stand: return FLinearColor(0.34f, 0.62f, 0.35f);
case ENpcActivity::Work: return FLinearColor(0.78f, 0.55f, 0.20f);
case ENpcActivity::Roam: return FLinearColor(0.66f, 0.35f, 0.55f);
default: break;
}
// A behaviour added later gets a colour of its own instead of the grey of an unknown: the value is
// spread over the colour wheel by the golden angle, which lands the next few far from each other
// and from the six above without anyone having to come back here to choose it.
return FLinearColor::MakeFromHSV8(static_cast<uint8>(static_cast<uint8>(Activity) * 137), 140, 190);
}
/** "22:00" for a minute of the day. Culture invariant, because a clock is digits and not prose. */
FText MakeClockText(int32 MinutesOfDay)
{
const int32 Wrapped = ((MinutesOfDay % MinutesPerDay) + MinutesPerDay) % MinutesPerDay;
return FText::AsCultureInvariant(FString::Printf(TEXT("%02d:%02d"), Wrapped / 60, Wrapped % 60));
}
/** "22:00 - 06:00" for a window, which is the whole of what a slot has to say about when it is. */
FText MakeWindowText(int32 StartMinutes, int32 EndMinutes)
{
return FText::AsCultureInvariant(FString::Printf(
TEXT("%s - %s"), *MakeClockText(StartMinutes).ToString(), *MakeClockText(EndMinutes).ToString()));
}
/** What an action's anchor is called on the legend line: the name of the actor the id resolves to in
* this level, or what it means when it resolves to nothing. An id no actor claims is a real state -
* furniture that was deleted, or furniture in a cell that is not loaded - and not one to hide, so it
* is named with the id it is, which is also the only way to tell two unresolved anchors apart. */
FText MakeAnchorText(const FGuid& AnchorId, const TMap<FGuid, FString>& AnchorNames)
{
if (!AnchorId.IsValid())
{
return LOCTEXT("NoAnchor", "no anchor");
}
if (const FString* Name = AnchorNames.Find(AnchorId))
{
return FText::FromString(*Name);
}
return FText::Format(
LOCTEXT("AnchorNotInLevel", "an anchor no actor in this level claims ({0})"),
FText::AsCultureInvariant(AnchorId.ToString(EGuidFormats::Digits)));
}
/** "Sleep 22:00 - 06:00 Bed_01" for the legend, which is where an action is named in full. */
FText MakeLegendLine(const FText& ActivityText, const FText& WindowText, const FText& AnchorText)
{
return FText::Format(LOCTEXT("LegendLine", "{0} {1} {2}"), ActivityText, WindowText, AnchorText);
}
}
/*-----------------------------------------------------------------------------
The widget
-----------------------------------------------------------------------------*/
void SNpcScheduleTimeline::Construct(const FArguments& InArgs)
{
Schedule = InArgs._Schedule;
Font = FCoreStyle::GetDefaultFontStyle(TEXT("Regular"), 8);
// The strip paints itself and holds no widget of its own, and it says so with an empty child slot
// rather than by leaving the slot unset: the slot is what a compound widget arranges, and this is
// the one child it has to be given to arrange.
ChildSlot
[
SNullWidget::NullWidget
];
// The ruler is the same eight labels whatever is being drawn, so it is made once here rather than
// rebuilt along with the schedules that share it.
for (int32 Hour = 0; Hour < 24; Hour += LabelledHourInterval)
{
HourLabels.Add(FText::AsCultureInvariant(FString::Printf(TEXT("%02d:00"), Hour)));
}
// Built here rather than left to the first tick, so that the first paint has a day to draw rather
// than an empty one. The time it is built at is 0 because there is no clock here yet, which is
// earlier than any anchor's name is trusted for: the first tick reads the level once more and
// leaves Construct with nothing to decide about when a name goes stale.
Rebuild(ComputeSignature(), 0.0);
}
void SNpcScheduleTimeline::Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime)
{
const uint32 Signature = ComputeSignature();
const bool bScheduleChanged = Signature != CachedSignature;
const bool bAnchorNamesStale = InCurrentTime - LastBuildTime >= AnchorLabelRefreshPeriod;
if (!bScheduleChanged && !bAnchorNamesStale)
{
return;
}
Rebuild(Signature, InCurrentTime);
// What has been painted was built from the schedule as it was, so the strip has to be painted
// again. An anchor renamed out from under an open tab is the only reason this happens without the
// asset changing, which is a repaint of one row every couple of seconds at worst: cheaper than
// keeping a subscription to every way an actor can be renamed, deleted or replaced by another.
Invalidate(EInvalidateWidgetReason::Paint);
}
uint32 SNpcScheduleTimeline::ComputeSignature() const
{
const UNpcScheduleAsset* Asset = Schedule.Get();
if (Asset == nullptr)
{
return 0;
}
// Every value the strip draws is in here, so that anything which would change the day, the
// windows or the names on them comes out as a different signature. The anchor's recorded transform
// is deliberately not: the strip never draws where an anchor stands, so a recheck that refreshed
// one has nothing here to say.
uint32 Signature = PointerHash(Asset);
for (const FScheduledAction& Action : Asset->ScheduledActions)
{
Signature = HashCombine(Signature,
GetTypeHash(Action.StartTime.GetMinutesOfDay()),
GetTypeHash(static_cast<int32>(Action.Activity)),
GetTypeHash(Action.UsableObjectGuid));
}
return Signature;
}
void SNpcScheduleTimeline::Rebuild(uint32 InSignature, double InCurrentTime)
{
CachedSignature = InSignature;
LastBuildTime = InCurrentTime;
Slots.Reset();
LaneCount = 0;
const UNpcScheduleAsset* Asset = Schedule.Get();
if (Asset == nullptr || Asset->ScheduledActions.Num() == 0)
{
return;
}
// The day is read out of the starts and out of nothing else, because a start is all an action
// holds: sorted, so that the next entry is the next start in the day, and kept in the schedule's
// own order where two starts are equal, so that the lanes a tied pair lands on do not swap places
// between one rebuild and the next.
struct FStart
{
int32 Minutes = 0;
int32 ActionIndex = 0;
};
TArray<FStart> Starts;
Starts.Reserve(Asset->ScheduledActions.Num());
for (int32 ActionIndex = 0; ActionIndex < Asset->ScheduledActions.Num(); ++ActionIndex)
{
Starts.Add(FStart{ Asset->ScheduledActions[ActionIndex].StartTime.GetMinutesOfDay(), ActionIndex });
}
Starts.Sort([](const FStart& A, const FStart& B)
{
return A.Minutes != B.Minutes ? A.Minutes < B.Minutes : A.ActionIndex < B.ActionIndex;
});
// The anchors are named with one walk of the open level rather than one per action, and the walk is
// skipped entirely - which is most of the time - when no action in this schedule names one.
TMap<FGuid, FString> AnchorNames;
const bool bAnchorNamed = Asset->ScheduledActions.ContainsByPredicate([](const FScheduledAction& Action)
{
return Action.UsableObjectGuid.IsValid();
});
if (bAnchorNamed)
{
CollectAnchorNames(AnchorNames);
}
for (int32 SortedIndex = 0; SortedIndex < Starts.Num(); ++SortedIndex)
{
const FStart Start = Starts[SortedIndex];
const FScheduledAction& Action = Asset->ScheduledActions[Start.ActionIndex];
// The next start that is a different one, looked for rather than read off the next entry,
// because two actions authored at the same minute claim one window between them and are told
// apart by their lane instead of by a window of their own.
int32 NextMinutes = INDEX_NONE;
for (int32 LaterIndex = SortedIndex + 1; LaterIndex < Starts.Num(); ++LaterIndex)
{
if (Starts[LaterIndex].Minutes != Start.Minutes)
{
NextMinutes = Starts[LaterIndex].Minutes;
break;
}
}
// No later start at all means the day wraps round and the earliest start closes this window,
// which is the 06:00 that closes a 22:00-06:00 night. When that earliest start is this one's
// own minute then every action in the schedule was authored at one minute, and there is no
// window to be had between them: the whole day is theirs, which is how a schedule whose start
// and end are the same is read, see FScheduledAction.
bool bWholeDay = false;
if (NextMinutes == INDEX_NONE)
{
NextMinutes = Starts[0].Minutes;
bWholeDay = NextMinutes == Start.Minutes;
if (!bWholeDay)
{
NextMinutes += MinutesPerDay;
}
}
// The lane is however many actions before this one in the day claim the same window. Windows
// worked out this way never overlap unless they are the same window, so one lane per earlier
// claim is the whole of the separation there is to make.
int32 LaneIndex = 0;
for (int32 EarlierIndex = 0; EarlierIndex < SortedIndex; ++EarlierIndex)
{
if (Starts[EarlierIndex].Minutes == Start.Minutes)
{
++LaneIndex;
}
}
LaneCount = FMath::Max(LaneCount, LaneIndex + 1);
const FText ActivityText = GetActivityLabel(Action.Activity);
const FText WindowText = bWholeDay
? LOCTEXT("WholeDayWindow", "all day")
: MakeWindowText(Start.Minutes, NextMinutes);
const FText AnchorText = MakeAnchorText(Action.UsableObjectGuid, AnchorNames);
FSlotView& Slot = Slots.AddDefaulted_GetRef();
// A whole day is drawn from midnight rather than from the minute it was authored at: the strip
// is the day, and an action that covers it covers all of it.
Slot.StartMinutes = bWholeDay ? 0 : Start.Minutes;
Slot.EndMinutes = bWholeDay ? MinutesPerDay : NextMinutes;
Slot.LaneIndex = LaneIndex;
Slot.Color = GetActivityColor(Action.Activity);
Slot.BlockLabel = FText::Format(LOCTEXT("SlotLabel", "{0} {1}"), ActivityText, WindowText);
Slot.LegendLabel = MakeLegendLine(ActivityText, WindowText, AnchorText);
}
}
FVector2D SNpcScheduleTimeline::ComputeDesiredSize(float LayoutScaleMultiplier) const
{
const int32 Lanes = FMath::Max(1, LaneCount);
const float DayHeight = Lanes * LaneHeight + (Lanes - 1) * LaneGap;
const float LegendHeight = Slots.Num() > 0 ? LegendTopGap + Slots.Num() * LegendRowHeight : 0.0f;
return FVector2D(PreferredWidth, HourLabelHeight + DayHeight + LegendHeight + BottomPadding);
}
int32 SNpcScheduleTimeline::OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const
{
// The width is read here and nowhere else. Everything below is placed against whatever the panel
// gave the row this frame, which is the whole of how the strip follows the panel being resized
// without anybody having to lay it out again.
const FVector2f LocalSize = AllottedGeometry.GetLocalSize();
const float StripLeft = StripLeftPadding;
const float StripWidth = FMath::Max(1.0f, LocalSize.X - StripLeftPadding - StripRightPadding);
const int32 Lanes = FMath::Max(1, LaneCount);
const float DayTop = HourLabelHeight;
const float DayBottom = DayTop + Lanes * LaneHeight + (Lanes - 1) * LaneGap;
const float LegendTop = DayBottom + LegendTopGap;
const FSlateBrush* const WhiteBrush = FCoreStyle::Get().GetBrush(TEXT("WhiteBrush"));
const ESlateDrawEffect DrawEffects = bParentEnabled
? ESlateDrawEffect::None
: ESlateDrawEffect::DisabledEffect;
/** A minute of the day as a place along the strip. */
const auto XOfMinute = [StripLeft, StripWidth](int32 MinutesOfDay)
{
return StripLeft + (StripWidth * static_cast<float>(MinutesOfDay)) / static_cast<float>(MinutesPerDay);
};
/** The top of a lane: the day's own rows, stacked under the ruler. */
const auto LaneTop = [DayTop](int32 LaneIndex)
{
return DayTop + LaneIndex * (LaneHeight + LaneGap);
};
const auto DrawRect = [&](float X, float Y, float RectWidth, float RectHeight, const FLinearColor& Color, int32 Layer)
{
if (RectWidth <= 0.0f || RectHeight <= 0.0f)
{
return;
}
FSlateDrawElement::MakeBox(OutDrawElements, Layer,
AllottedGeometry.ToPaintGeometry(FVector2f(RectWidth, RectHeight), FSlateLayoutTransform(FVector2f(X, Y))),
WhiteBrush, DrawEffects, Color);
};
const auto DrawText = [&](float X, float Y, float TextWidth, float TextHeight, const FText& Text, const FLinearColor& Color, int32 Layer)
{
if (TextWidth <= 0.0f)
{
return;
}
FSlateDrawElement::MakeText(OutDrawElements, Layer,
AllottedGeometry.ToPaintGeometry(FVector2f(TextWidth, TextHeight), FSlateLayoutTransform(FVector2f(X, Y))),
Text, Font, DrawEffects, Color);
};
/* The trough, the lanes in it, and the hour marks over both, so that an hour reads as a line
across a whole day rather than as a mark inside one lane. The marks are the ruler's and the slots
are drawn over them: an hour inside a slot is drawn dark on that slot's own colour, see below. */
DrawRect(StripLeft, DayTop, StripWidth, DayBottom - DayTop, TroughColor, LayerId);
for (int32 LaneIndex = 0; LaneIndex < Lanes; ++LaneIndex)
{
DrawRect(StripLeft, LaneTop(LaneIndex), StripWidth, LaneHeight, LaneColor, LayerId);
}
for (int32 Hour = 0; Hour <= 24; ++Hour)
{
const float HourX = XOfMinute(Hour * 60);
const bool bLabelled = Hour % LabelledHourInterval == 0;
DrawRect(HourX, DayTop, 1.0f, DayBottom - DayTop,
bLabelled ? LabelledHourMarkColor : HourMarkColor, LayerId + 1);
if (bLabelled && Hour < 24)
{
// Drawn to the right of its own mark rather than centred on it, so that none of the labels
// is ever cut in half by the edge of the strip.
DrawText(HourX + 3.0f, 0.0f, StripWidth - (HourX + 3.0f - StripLeft), HourLabelHeight,
HourLabels[Hour / LabelledHourInterval], HourLabelColor, LayerId + 1);
}
}
/* One half of a slot. The day ends at midnight, so a window that runs past it is drawn as two
halves with a marker on the edges where they would meet, and either half names the window it is
part of: the half with room to say when the whole of it is says it, and is the same answer at
both ends of the strip. */
const auto DrawSlotHalf = [&](float X, float RectWidth, float Y, const FSlotView& Slot, float MarkerX)
{
DrawRect(X, Y, RectWidth, LaneHeight, Slot.Color, LayerId + 2);
// A brighter edge along the top, so that two slots of the same colour meeting at an hour are
// still two slots.
FLinearColor TopColor = Slot.Color * 1.35f;
TopColor.A = 1.0f;
DrawRect(X, Y, RectWidth, 2.0f, TopColor, LayerId + 3);
if (MarkerX >= 0.0f)
{
DrawRect(MarkerX, Y, WrapMarkerWidth, LaneHeight, WrapMarkerColor, LayerId + 3);
}
if (RectWidth < MinimumLabelledSlotWidth)
{
// Nothing is legible in a slot this narrow, and a label spilling out of its own slot would
// read as belonging to the one beside it. The legend below names it in full instead.
return;
}
// Clipped to the slot it names, for the same reason: a slot that cannot hold its whole label
// shows as much of it as it can and not a letter of the next slot's.
const FGeometry HalfGeometry = AllottedGeometry.MakeChild(
FVector2f(RectWidth, LaneHeight), FSlateLayoutTransform(FVector2f(X, Y)));
const FGeometry LabelGeometry = HalfGeometry.MakeChild(
FVector2f(FMath::Max(0.0f, RectWidth - SlotLabelInset * 2.0f), SlotLabelHeight),
FSlateLayoutTransform(FVector2f(SlotLabelInset, (LaneHeight - SlotLabelHeight) * 0.5f)));
OutDrawElements.PushClip(FSlateClippingZone(HalfGeometry));
FSlateDrawElement::MakeText(OutDrawElements, LayerId + 4, LabelGeometry.ToPaintGeometry(),
Slot.BlockLabel, Font, DrawEffects, SlotLabelColor);
OutDrawElements.PopClip();
};
for (const FSlotView& Slot : Slots)
{
const float Y = LaneTop(Slot.LaneIndex);
// The part of the slot that fits inside the day, which is all of it unless the window wraps.
const int32 FirstEndMinutes = FMath::Min(Slot.EndMinutes, MinutesPerDay);
const float FirstX = XOfMinute(Slot.StartMinutes);
const float FirstWidth = XOfMinute(FirstEndMinutes) - FirstX;
const bool bWraps = Slot.EndMinutes > MinutesPerDay;
DrawSlotHalf(FirstX, FirstWidth, Y, Slot,
bWraps ? XOfMinute(MinutesPerDay) - WrapMarkerWidth : -1.0f);
if (bWraps)
{
// The rest of the window, at the start of the same day: a 22:00-06:00 night is a slot from
// 22:00 to midnight here, and from midnight to 06:00 at the left edge of the strip.
DrawSlotHalf(StripLeft, XOfMinute(Slot.EndMinutes - MinutesPerDay) - StripLeft, Y, Slot, StripLeft);
}
}
if (Slots.Num() == 0)
{
// Said in the trough rather than left blank, because a day with nothing in it is either a
// schedule nobody has written yet or an asset that is no longer there, and the two read the
// same from here without something to tell them apart.
DrawText(StripLeft + SlotLabelInset, DayTop + (LaneHeight - SlotLabelHeight) * 0.5f,
StripWidth - SlotLabelInset * 2.0f, SlotLabelHeight,
Schedule.IsValid()
? LOCTEXT("EmptySchedule", "No actions in this schedule, so the day is empty.")
: LOCTEXT("NoSchedule", "No schedule is being drawn."),
QuietTextColor, LayerId + 1);
}
else
{
// The legend, in the order the day reads rather than the order the array holds, so that
// walking it left to right walks the slots left to right as well.
int32 RowIndex = 0;
for (const FSlotView& Slot : Slots)
{
const float RowY = LegendTop + RowIndex * LegendRowHeight;
DrawRect(StripLeft, RowY + (LegendRowHeight - LegendSwatchWidth) * 0.5f,
LegendSwatchWidth, LegendSwatchWidth, Slot.Color, LayerId + 5);
DrawText(StripLeft + LegendSwatchWidth + LegendSwatchGap, RowY,
FMath::Max(0.0f, StripWidth - LegendSwatchWidth - LegendSwatchGap), SlotLabelHeight,
Slot.LegendLabel, LegendTextColor, LayerId + 5);
++RowIndex;
}
}
return LayerId + 6;
}
#undef LOCTEXT_NAMESPACE

View File

@@ -10,6 +10,7 @@
#include "ItemDataAssetCustomization.h"
#include "ItemEditorWindow.h"
#include "NpcScheduleActionCustomization.h"
#include "NpcScheduleAssetCustomization.h"
#include "ScheduleAssetRecheck.h"
#define LOCTEXT_NAMESPACE "ProjectEleriEditorModule"
@@ -35,6 +36,14 @@ void FProjectEleriEditorModule::StartupModule()
FOnGetPropertyTypeCustomizationInstance::CreateStatic(&FNpcScheduleActionCustomization::MakeInstance)
);
// Details customization for the schedule asset itself, which draws the day its actions describe as
// a strip under everything else the panel shows. Registered under the class's script name, which is
// the C++ class name without its U prefix.
PropEd.RegisterCustomClassLayout(
TEXT("NpcScheduleAsset"),
FOnGetDetailCustomizationInstance::CreateStatic(&FNpcScheduleAssetCustomization::MakeInstance)
);
// Register the Item Editor as a docking tab.
if (FGlobalTabmanager::Get()->HasTabSpawner(ItemEditorTabName))
{
@@ -75,6 +84,7 @@ void FProjectEleriEditorModule::ShutdownModule()
FPropertyEditorModule& PropEd = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");
PropEd.UnregisterCustomClassLayout(TEXT("ItemDataAsset"));
PropEd.UnregisterCustomPropertyTypeLayout(TEXT("ScheduledAction"));
PropEd.UnregisterCustomClassLayout(TEXT("NpcScheduleAsset"));
}
if (FGlobalTabmanager::Get()->HasTabSpawner(ItemEditorTabName))

View File

@@ -0,0 +1,26 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "IDetailCustomization.h"
/**
* Details panel layout for UNpcScheduleAsset: everything the asset already had, with the day its
* actions describe drawn underneath it as a strip, see SNpcScheduleTimeline.
*
* All this does is put the strip there. It adds no row for any property and takes none away, so the
* actions themselves keep the rows the panel and FNpcScheduleActionCustomization give them: the strip
* is a view of what those rows say, and a second place to write an action would be a second answer to
* what a day is.
*
* The strip goes in a category of its own sorted after the one the asset's properties land in, which
* is what puts it at the bottom of the editor. A category is as close as a details customization can
* get to the bottom of anything: the panel is one scrolling column and everything in it is a row of
* it, so the bottom of the panel is the bottom of the layout, under every row rather than pinned to
* the edge of the window the way a status bar would be.
*/
class FNpcScheduleAssetCustomization : public IDetailCustomization {
public:
static TSharedRef<IDetailCustomization> MakeInstance();
virtual void CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) override;
};

View File

@@ -0,0 +1,119 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Fonts/SlateFontInfo.h"
#include "UObject/WeakObjectPtrTemplates.h"
#include "Widgets/SCompoundWidget.h"
class UNpcScheduleAsset;
/**
* A schedule's day drawn as one strip: an hour ruler across the top, one row of slots under it with
* the actions that claim them, and a legend under that naming every action, the window it holds and
* the anchor it happens at.
*
* It reads the day out of the actions' start times, because a start is all an FScheduledAction has:
* the end of a window is the next start in the schedule, which is also the rule the asset states for
* itself, so a 06:00-22:00 and a 22:00-06:00 pair tile the day, a window that runs past midnight is
* drawn as two halves of one slot with a marker where it carries on, and a schedule whose actions all
* start at one minute covers the whole day with each of them, which is what the game reads it as.
*
* Two actions authored at the same minute therefore claim one slot between them, and that is the only
* case that needs more than one row: the day is drawn on as many lanes as there are claims on the
* most-claimed minute, so a minute two actions fight over is seen rather than drawn twice in the same
* place.
*
* Nothing here is interactive. It says what the asset says and leaves the writing to the array above
* it, which is the one place an action can be authored.
*
* The strip draws itself from whatever width the panel gives it, read back at paint time, so it grows
* and shrinks with the details panel without ever being laid out again to do it. What is worked out
* ahead of painting - the slots, the ruler, the colours, the names - is worked out when the schedule
* changes and not while painting, because a rebuild is also what walks the level to name the anchors
* and that is not something to do once a frame.
*/
class SNpcScheduleTimeline : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SNpcScheduleTimeline)
: _Schedule(nullptr)
{
}
/** The asset to draw. Weak because a details panel outlives the asset it was opened on, and a
* deleted asset has to leave an empty strip rather than a dangling one. */
SLATE_ARGUMENT(TWeakObjectPtr<UNpcScheduleAsset>, Schedule)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs);
//~ Begin SWidget interface
virtual void Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime) override;
virtual int32 OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const override;
//~ End SWidget interface
protected:
virtual FVector2D ComputeDesiredSize(float LayoutScaleMultiplier) const override;
private:
/** One slot of the day: the window one action claims, which lane it is drawn on because something
* else claims that window too, and the two labels it is drawn with. Worked out by Rebuild. */
struct FSlotView
{
/** First minute of the day in the slot, 0 at midnight. */
int32 StartMinutes = 0;
/** The minute the slot ends at, exclusive, and past midnight rather than wrapped to it, so a
* slot that carries on at the start of the next day can be told from one that does not. */
int32 EndMinutes = 0;
/** Row the slot is drawn on. Only ever more than 0 under a window another action claims too. */
int32 LaneIndex = 0;
/** Colour of the activity, so that a legend row and its slot cannot disagree about it. */
FLinearColor Color = FLinearColor::Gray;
/** Activity and window, as it reads inside the slot: "Sleep 22:00 - 06:00". */
FText BlockLabel;
/** Activity, window and anchor, as it reads in the legend. Longer than BlockLabel because a
* slot can be far too narrow to hold anything and the legend is where the rest is said. */
FText LegendLabel;
};
/** A cheap read of everything the strip draws, so that a change can be noticed by reading the
* asset rather than by subscribing to every way one of its actions can be written: an element
* added, removed, reordered or edited inside a row all come out as a different signature here.
* What the strip does not draw is deliberately not in it, so moving an anchor to the side of the
* room it was already picked at does not make the strip rebuild itself. */
uint32 ComputeSignature() const;
/** Works the slots, the lanes and the legend out of the schedule, and names the anchors they point
* at with one walk of the open level. Does not repaint: the caller does that, because a rebuild
* during Construct happens before there is anything to repaint. */
void Rebuild(uint32 InSignature, double InCurrentTime);
/** The asset being drawn. Null while nothing has been assigned or after it was deleted. */
TWeakObjectPtr<UNpcScheduleAsset> Schedule;
/** Every slot of the day, in the order the day reads: earliest start first, and actions that share
* a start in the order the schedule holds them. The legend is drawn straight off this as well, so
* that a line and the slot it belongs to cannot come apart. */
TArray<FSlotView> Slots;
/** How many lanes the slots need. 1 whenever no two actions were authored at the same minute. */
int32 LaneCount = 0;
/** Labels of the hour ruler, one every third hour, made once because they never change. */
TArray<FText> HourLabels;
/** The signature of the schedule the strip was last built from. */
uint32 CachedSignature = 0;
/** When that build happened, so that the anchor names can be re-read on a slow timer. */
double LastBuildTime = 0.0;
/** The font the ruler, the slots and the legend are drawn in. */
FSlateFontInfo Font;
};