// 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