diff --git a/Source/ProjectEleriEditor/Private/NpcScheduleAssetCustomization.cpp b/Source/ProjectEleriEditor/Private/NpcScheduleAssetCustomization.cpp new file mode 100644 index 00000000..dfbeb83a --- /dev/null +++ b/Source/ProjectEleriEditor/Private/NpcScheduleAssetCustomization.cpp @@ -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 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> Objects; + DetailBuilder.GetObjectsBeingCustomized(Objects); + + UNpcScheduleAsset* Schedule = Objects.Num() > 0 ? Cast(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 diff --git a/Source/ProjectEleriEditor/Private/NpcScheduleTimeline.cpp b/Source/ProjectEleriEditor/Private/NpcScheduleTimeline.cpp new file mode 100644 index 00000000..e062adfe --- /dev/null +++ b/Source/ProjectEleriEditor/Private/NpcScheduleTimeline.cpp @@ -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(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& OutAnchorNames) +{ + UWorld* World = GEditor != nullptr ? GEditor->GetEditorWorldContext().World() : nullptr; + if (World == nullptr) + { + return; + } + + for (TActorIterator 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(); + return Enum != nullptr ? Enum->GetDisplayNameTextByValue(static_cast(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(static_cast(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& 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(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 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 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(MinutesOfDay)) / static_cast(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 diff --git a/Source/ProjectEleriEditor/Private/ProjectEleriEditorModule.cpp b/Source/ProjectEleriEditor/Private/ProjectEleriEditorModule.cpp index 6bef1783..413e1e6d 100644 --- a/Source/ProjectEleriEditor/Private/ProjectEleriEditorModule.cpp +++ b/Source/ProjectEleriEditor/Private/ProjectEleriEditorModule.cpp @@ -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("PropertyEditor"); PropEd.UnregisterCustomClassLayout(TEXT("ItemDataAsset")); PropEd.UnregisterCustomPropertyTypeLayout(TEXT("ScheduledAction")); + PropEd.UnregisterCustomClassLayout(TEXT("NpcScheduleAsset")); } if (FGlobalTabmanager::Get()->HasTabSpawner(ItemEditorTabName)) diff --git a/Source/ProjectEleriEditor/Public/NpcScheduleAssetCustomization.h b/Source/ProjectEleriEditor/Public/NpcScheduleAssetCustomization.h new file mode 100644 index 00000000..eee87fc7 --- /dev/null +++ b/Source/ProjectEleriEditor/Public/NpcScheduleAssetCustomization.h @@ -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 MakeInstance(); + virtual void CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) override; +}; diff --git a/Source/ProjectEleriEditor/Public/NpcScheduleTimeline.h b/Source/ProjectEleriEditor/Public/NpcScheduleTimeline.h new file mode 100644 index 00000000..35e313d1 --- /dev/null +++ b/Source/ProjectEleriEditor/Public/NpcScheduleTimeline.h @@ -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, 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 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 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 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; +};