Display for schedule data asset timeline, view only
This commit is contained in:
585
Source/ProjectEleriEditor/Private/NpcScheduleTimeline.cpp
Normal file
585
Source/ProjectEleriEditor/Private/NpcScheduleTimeline.cpp
Normal 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
|
||||
Reference in New Issue
Block a user