From baccddc0a82e464bc0d8de3cff161e95836b5de5 Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Mon, 27 Apr 2026 05:35:55 +0200 Subject: [PATCH] Add coach dashboard attempt composer --- .../HyperTwistCoachDashboardWidget.cpp | 324 +++++++++++++++++- .../HyperTwistCoachDashboardWidget.h | 55 +++ 2 files changed, 377 insertions(+), 2 deletions(-) diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp index 5684b61..df2748b 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp @@ -3,6 +3,7 @@ #include "Blueprint/WidgetTree.h" #include "Components/Button.h" #include "Components/ButtonSlot.h" +#include "Components/EditableTextBox.h" #include "Components/HorizontalBox.h" #include "Components/HorizontalBoxSlot.h" #include "Components/TextBlock.h" @@ -75,6 +76,28 @@ namespace HyperTwistCoachDashboardWidgetInternal return Button; } + + UEditableTextBox* AddEditableTextBox( + UWidgetTree* WidgetTree, + UHorizontalBox* Parent, + const FName WidgetName) + { + UEditableTextBox* TextBox = WidgetTree->ConstructWidget( + UEditableTextBox::StaticClass(), + WidgetName + ); + if (TextBox == nullptr) + { + return nullptr; + } + + if (UHorizontalBoxSlot* Slot = Parent->AddChildToHorizontalBox(TextBox)) + { + Slot->SetPadding(FMargin(0.0f, 0.0f, 8.0f, 0.0f)); + Slot->SetSize(FSlateChildSize(ESlateSizeRule::Fill)); + } + return TextBox; + } } void UHyperTwistCoachDashboardWidget::NativeConstruct() @@ -226,6 +249,15 @@ FHyperTwistTrainingRunStepResult UHyperTwistCoachDashboardWidget::SubmitSyntheti Attempt.ExecutionTimeMs = FMath::Max(TimeMs, 0); Attempt.TimingBreakdown.RawSolveTimeMs = FMath::Max(TimeMs, 0); Attempt.TimingBreakdown.FinalTimeMs = FMath::Max(TimeMs, 0); + Attempt.TimingBreakdown.bDidNotFinish = + Result == EHyperTwistTrainingAttemptResult::Timeout + || Result == EHyperTwistTrainingAttemptResult::DNF + || Result == EHyperTwistTrainingAttemptResult::Aborted; + if (Result == EHyperTwistTrainingAttemptResult::DNF) + { + Attempt.TimingBreakdown.ManualPenalty = EHyperTwistTrainingPenalty::DNF; + Attempt.TimingBreakdown.AppliedPenalty = EHyperTwistTrainingPenalty::DNF; + } Attempt.ReplayId = CachedRunState.ReplayPacket.ReplayId; Attempt.CompletedAtUtc = FDateTime::UtcNow().ToIso8601(); @@ -283,14 +315,59 @@ void UHyperTwistCoachDashboardWidget::HandleClearRunClicked() void UHyperTwistCoachDashboardWidget::HandleRecordSuccessClicked() { - SubmitSyntheticCurrentAttempt(EHyperTwistTrainingAttemptResult::Success, DefaultSuccessAttemptTimeMs); + SubmitSyntheticCurrentAttempt( + EHyperTwistTrainingAttemptResult::Success, + ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Success) + ); SyncSelectedQueueEntry(); UpdateDashboardPresentation(); } void UHyperTwistCoachDashboardWidget::HandleRecordFailureClicked() { - SubmitSyntheticCurrentAttempt(EHyperTwistTrainingAttemptResult::Failure, DefaultFailureAttemptTimeMs); + SubmitSyntheticCurrentAttempt( + EHyperTwistTrainingAttemptResult::Failure, + ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Failure) + ); + SyncSelectedQueueEntry(); + UpdateDashboardPresentation(); +} + +void UHyperTwistCoachDashboardWidget::HandleRecordTimeoutClicked() +{ + SubmitSyntheticCurrentAttempt( + EHyperTwistTrainingAttemptResult::Timeout, + ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Timeout) + ); + SyncSelectedQueueEntry(); + UpdateDashboardPresentation(); +} + +void UHyperTwistCoachDashboardWidget::HandleRecordDnfClicked() +{ + SubmitSyntheticCurrentAttempt( + EHyperTwistTrainingAttemptResult::DNF, + ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::DNF) + ); + SyncSelectedQueueEntry(); + UpdateDashboardPresentation(); +} + +void UHyperTwistCoachDashboardWidget::HandleUseTargetTimeClicked() +{ + if (CachedRunState.CurrentSelection.TrainingCase.TimeTargetMs > 0) + { + SetAttemptTimeText(CachedRunState.CurrentSelection.TrainingCase.TimeTargetMs); + } + UpdateDashboardPresentation(); +} + +void UHyperTwistCoachDashboardWidget::HandleUseAverageTimeClicked() +{ + if (CachedRunSummary.AverageTotalTimeMs > 0) + { + SetAttemptTimeText(CachedRunSummary.AverageTotalTimeMs); + } SyncSelectedQueueEntry(); UpdateDashboardPresentation(); } @@ -383,6 +460,18 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() WidgetTree, RootLayout, TEXT("CoachCurrentCaseNotation"), + 2 + ); + AttemptControlHeaderTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow( + WidgetTree, + RootLayout, + TEXT("CoachAttemptControlHeader"), + 2 + ); + AttemptInputStatusTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow( + WidgetTree, + RootLayout, + TEXT("CoachAttemptInputStatus"), 8 ); FollowUpTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow( @@ -514,6 +603,50 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() PromoteButtonLabel = PromoteLabelRaw; } + UHorizontalBox* AttemptInputRow = WidgetTree->ConstructWidget( + UHorizontalBox::StaticClass(), + TEXT("CoachDashboardAttemptInputRow") + ); + if (AttemptInputRow != nullptr) + { + if (UVerticalBoxSlot* AttemptInputRowSlot = RootLayout->AddChildToVerticalBox(AttemptInputRow)) + { + AttemptInputRowSlot->SetPadding(FMargin(0.0f, 4.0f, 0.0f, 0.0f)); + } + + AttemptTimeTextBox = HyperTwistCoachDashboardWidgetInternal::AddEditableTextBox( + WidgetTree, + AttemptInputRow, + TEXT("AttemptTimeTextBox") + ); + if (AttemptTimeTextBox != nullptr) + { + AttemptTimeTextBox->SetHintText(FText::FromString(TEXT("Enter attempt ms"))); + } + + UTextBlock* UseTargetTimeLabelRaw = nullptr; + UseTargetTimeButton = HyperTwistCoachDashboardWidgetInternal::AddButton( + WidgetTree, + AttemptInputRow, + TEXT("UseTargetTimeButton"), + TEXT("UseTargetTimeButtonLabel"), + TEXT("Use Target"), + UseTargetTimeLabelRaw + ); + UseTargetTimeButtonLabel = UseTargetTimeLabelRaw; + + UTextBlock* UseAverageTimeLabelRaw = nullptr; + UseAverageTimeButton = HyperTwistCoachDashboardWidgetInternal::AddButton( + WidgetTree, + AttemptInputRow, + TEXT("UseAverageTimeButton"), + TEXT("UseAverageTimeButtonLabel"), + TEXT("Use Avg"), + UseAverageTimeLabelRaw + ); + UseAverageTimeButtonLabel = UseAverageTimeLabelRaw; + } + UHorizontalBox* RunControlRow = WidgetTree->ConstructWidget( UHorizontalBox::StaticClass(), TEXT("CoachDashboardRunControlButtons") @@ -568,6 +701,28 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() RecordFailureLabelRaw ); RecordFailureButtonLabel = RecordFailureLabelRaw; + + UTextBlock* RecordTimeoutLabelRaw = nullptr; + RecordTimeoutButton = HyperTwistCoachDashboardWidgetInternal::AddButton( + WidgetTree, + RunControlRow, + TEXT("RecordTimeoutButton"), + TEXT("RecordTimeoutButtonLabel"), + TEXT("Record Timeout"), + RecordTimeoutLabelRaw + ); + RecordTimeoutButtonLabel = RecordTimeoutLabelRaw; + + UTextBlock* RecordDnfLabelRaw = nullptr; + RecordDnfButton = HyperTwistCoachDashboardWidgetInternal::AddButton( + WidgetTree, + RunControlRow, + TEXT("RecordDnfButton"), + TEXT("RecordDnfButtonLabel"), + TEXT("Record DNF"), + RecordDnfLabelRaw + ); + RecordDnfButtonLabel = RecordDnfLabelRaw; } UHorizontalBox* QueueNavRow = WidgetTree->ConstructWidget( @@ -647,6 +802,22 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() { RecordFailureButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleRecordFailureClicked); } + if (RecordTimeoutButton != nullptr) + { + RecordTimeoutButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleRecordTimeoutClicked); + } + if (RecordDnfButton != nullptr) + { + RecordDnfButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleRecordDnfClicked); + } + if (UseTargetTimeButton != nullptr) + { + UseTargetTimeButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleUseTargetTimeClicked); + } + if (UseAverageTimeButton != nullptr) + { + UseAverageTimeButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleUseAverageTimeClicked); + } if (PreviousQueueButton != nullptr) { PreviousQueueButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandlePreviousQueueClicked); @@ -727,6 +898,12 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() ? (CachedRunState.ActiveDeck.DeckId.IsEmpty() ? TEXT("n/a") : CachedRunState.ActiveDeck.DeckId) : CachedRunState.ActiveDeck.Title; const bool bHasRunnableCurrentCase = bHasActiveRun && CachedRunState.CurrentSelection.TrainingCase.IsStructurallyValid(); + int32 ParsedAttemptOverrideMs = 0; + const bool bHasParsedAttemptOverride = TryParseAttemptTimeOverride(ParsedAttemptOverrideMs); + const int32 ResolvedSuccessTimeMs = ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Success); + const int32 ResolvedFailureTimeMs = ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Failure); + const int32 ResolvedTimeoutTimeMs = ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Timeout); + const int32 ResolvedDnfTimeMs = ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::DNF); const FString VerificationNotePreview = CachedRepositoryRoundTripVerification.VerificationNotes.Num() > 0 ? CachedRepositoryRoundTripVerification.VerificationNotes[0] : TEXT("none"); @@ -848,6 +1025,31 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() )); } } + if (AttemptControlHeaderTextBlock != nullptr) + { + AttemptControlHeaderTextBlock->SetText(FText::FromString(TEXT("[Attempt Controls]"))); + } + if (AttemptInputStatusTextBlock != nullptr) + { + AttemptInputStatusTextBlock->SetText(FText::FromString(FString::Printf( + TEXT("Attempt ms input: %s | Target %d | Avg %d | Success %d | Failure %d | Timeout %d | DNF %d"), + bHasParsedAttemptOverride ? *FString::FromInt(ParsedAttemptOverrideMs) : TEXT("auto"), + CurrentTrainingCase.TimeTargetMs, + CachedRunSummary.AverageTotalTimeMs, + ResolvedSuccessTimeMs, + ResolvedFailureTimeMs, + ResolvedTimeoutTimeMs, + ResolvedDnfTimeMs + ))); + } + if (AttemptTimeTextBox != nullptr) + { + const FString HintLabel = CurrentTrainingCase.TimeTargetMs > 0 + ? FString::Printf(TEXT("Enter attempt ms (target %d)"), CurrentTrainingCase.TimeTargetMs) + : TEXT("Enter attempt ms"); + AttemptTimeTextBox->SetHintText(FText::FromString(HintLabel)); + AttemptTimeTextBox->SetIsEnabled(bHasRunnableCurrentCase); + } if (FollowUpTextBlock != nullptr) { const FString FollowUpLine = @@ -1093,6 +1295,38 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() { RecordFailureButton->SetIsEnabled(bHasRunnableCurrentCase); } + if (RecordTimeoutButtonLabel != nullptr) + { + RecordTimeoutButtonLabel->SetText(FText::FromString(TEXT("Record Timeout"))); + } + if (RecordTimeoutButton != nullptr) + { + RecordTimeoutButton->SetIsEnabled(bHasRunnableCurrentCase); + } + if (RecordDnfButtonLabel != nullptr) + { + RecordDnfButtonLabel->SetText(FText::FromString(TEXT("Record DNF"))); + } + if (RecordDnfButton != nullptr) + { + RecordDnfButton->SetIsEnabled(bHasRunnableCurrentCase); + } + if (UseTargetTimeButtonLabel != nullptr) + { + UseTargetTimeButtonLabel->SetText(FText::FromString(TEXT("Use Target"))); + } + if (UseTargetTimeButton != nullptr) + { + UseTargetTimeButton->SetIsEnabled(bHasRunnableCurrentCase && CurrentTrainingCase.TimeTargetMs > 0); + } + if (UseAverageTimeButtonLabel != nullptr) + { + UseAverageTimeButtonLabel->SetText(FText::FromString(TEXT("Use Avg"))); + } + if (UseAverageTimeButton != nullptr) + { + UseAverageTimeButton->SetIsEnabled(bHasActiveRun && CachedRunSummary.AverageTotalTimeMs > 0); + } if (PreviousQueueButtonLabel != nullptr) { PreviousQueueButtonLabel->SetText(FText::FromString(TEXT("Prev Queue"))); @@ -1144,6 +1378,92 @@ FString UHyperTwistCoachDashboardWidget::FindFirstDeferredQueueEntryId() const return FString(); } +bool UHyperTwistCoachDashboardWidget::TryParseAttemptTimeOverride(int32& OutTimeMs) const +{ + OutTimeMs = 0; + if (AttemptTimeTextBox == nullptr) + { + return false; + } + + const FString RawText = AttemptTimeTextBox->GetText().ToString().TrimStartAndEnd(); + if (RawText.IsEmpty()) + { + return false; + } + + int32 ParsedValue = 0; + if (!LexTryParseString(ParsedValue, *RawText)) + { + return false; + } + + OutTimeMs = FMath::Max(ParsedValue, 0); + return true; +} + +int32 UHyperTwistCoachDashboardWidget::ResolveAttemptTimeMs(const EHyperTwistTrainingAttemptResult Result) const +{ + int32 ParsedOverrideMs = 0; + if (TryParseAttemptTimeOverride(ParsedOverrideMs)) + { + return ParsedOverrideMs; + } + + const int32 TargetTimeMs = FMath::Max(CachedRunState.CurrentSelection.TrainingCase.TimeTargetMs, 0); + const int32 AverageTimeMs = FMath::Max(CachedRunSummary.AverageTotalTimeMs, 0); + + switch (Result) + { + case EHyperTwistTrainingAttemptResult::Success: + if (TargetTimeMs > 0) + { + return TargetTimeMs; + } + if (AverageTimeMs > 0) + { + return AverageTimeMs; + } + return FMath::Max(DefaultSuccessAttemptTimeMs, 1); + + case EHyperTwistTrainingAttemptResult::Failure: + if (AverageTimeMs > 0) + { + return FMath::Max(AverageTimeMs, DefaultFailureAttemptTimeMs); + } + if (TargetTimeMs > 0) + { + return FMath::Max(TargetTimeMs * 2, DefaultFailureAttemptTimeMs); + } + return FMath::Max(DefaultFailureAttemptTimeMs, 1); + + case EHyperTwistTrainingAttemptResult::Timeout: + if (TargetTimeMs > 0) + { + return FMath::Max(TargetTimeMs * 2, DefaultTimeoutAttemptTimeMs); + } + return FMath::Max(DefaultTimeoutAttemptTimeMs, 1); + + case EHyperTwistTrainingAttemptResult::DNF: + if (TargetTimeMs > 0) + { + return FMath::Max(TargetTimeMs * 2, DefaultDnfAttemptTimeMs); + } + return FMath::Max(DefaultDnfAttemptTimeMs, 1); + + default: + return FMath::Max(DefaultFailureAttemptTimeMs, 1); + } +} + +void UHyperTwistCoachDashboardWidget::SetAttemptTimeText(const int32 TimeMs) +{ + if (AttemptTimeTextBox != nullptr) + { + AttemptTimeTextBox->SetText(FText::FromString(FString::FromInt(FMath::Max(TimeMs, 0)))); + } +} + const FHyperTwistTrainingCoachSessionQueueStateEntry* UHyperTwistCoachDashboardWidget::FindSelectedQueueEntry() const { if (SelectedQueueEntryIndex < 0 || SelectedQueueEntryIndex >= CachedCoachSessionQueueState.Entries.Num()) diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h index 3a56c98..37f5463 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h @@ -5,6 +5,7 @@ #include "HyperTwistCoachDashboardWidget.generated.h" class UButton; +class UEditableTextBox; class UTextBlock; class UVerticalBox; @@ -25,6 +26,12 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Coach|Dashboard") int32 DefaultFailureAttemptTimeMs = 30000; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Coach|Dashboard") + int32 DefaultTimeoutAttemptTimeMs = 45000; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Coach|Dashboard") + int32 DefaultDnfAttemptTimeMs = 60000; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Coach|Dashboard") bool bPreferActiveQueueSelectionOnRefresh = true; @@ -92,6 +99,15 @@ protected: UPROPERTY(Transient) TObjectPtr CurrentCaseNotationTextBlock = nullptr; + UPROPERTY(Transient) + TObjectPtr AttemptControlHeaderTextBlock = nullptr; + + UPROPERTY(Transient) + TObjectPtr AttemptInputStatusTextBlock = nullptr; + + UPROPERTY(Transient) + TObjectPtr AttemptTimeTextBox = nullptr; + UPROPERTY(Transient) TObjectPtr QueueStatusTextBlock = nullptr; @@ -176,6 +192,30 @@ protected: UPROPERTY(Transient) TObjectPtr RecordFailureButtonLabel = nullptr; + UPROPERTY(Transient) + TObjectPtr RecordTimeoutButton = nullptr; + + UPROPERTY(Transient) + TObjectPtr RecordTimeoutButtonLabel = nullptr; + + UPROPERTY(Transient) + TObjectPtr RecordDnfButton = nullptr; + + UPROPERTY(Transient) + TObjectPtr RecordDnfButtonLabel = nullptr; + + UPROPERTY(Transient) + TObjectPtr UseTargetTimeButton = nullptr; + + UPROPERTY(Transient) + TObjectPtr UseTargetTimeButtonLabel = nullptr; + + UPROPERTY(Transient) + TObjectPtr UseAverageTimeButton = nullptr; + + UPROPERTY(Transient) + TObjectPtr UseAverageTimeButtonLabel = nullptr; + UPROPERTY(Transient) TObjectPtr PreviousQueueButton = nullptr; @@ -218,6 +258,18 @@ protected: UFUNCTION() void HandleRecordFailureClicked(); + UFUNCTION() + void HandleRecordTimeoutClicked(); + + UFUNCTION() + void HandleRecordDnfClicked(); + + UFUNCTION() + void HandleUseTargetTimeClicked(); + + UFUNCTION() + void HandleUseAverageTimeClicked(); + UFUNCTION() void HandlePreviousQueueClicked(); @@ -233,5 +285,8 @@ private: void UpdateDashboardPresentation(); FString BuildDefaultDeferredUntilUtc() const; FString FindFirstDeferredQueueEntryId() const; + bool TryParseAttemptTimeOverride(int32& OutTimeMs) const; + int32 ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult Result) const; + void SetAttemptTimeText(int32 TimeMs); const FHyperTwistTrainingCoachSessionQueueStateEntry* FindSelectedQueueEntry() const; };