diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp index 57240ff..6a775b4 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp @@ -134,6 +134,7 @@ void UHyperTwistCoachDashboardWidget::RefreshCoachDashboardView() LiveAttemptInspectionElapsedMs = 0; LiveAttemptSolveElapsedMs = 0; LiveAttemptDisplayedMs = 0; + LiveAttemptSplitCaptures.Reset(); } else { @@ -320,6 +321,7 @@ bool UHyperTwistCoachDashboardWidget::StartLiveAttemptTimer() LiveAttemptInspectionElapsedMs = 0; LiveAttemptSolveElapsedMs = 0; LiveAttemptDisplayedMs = 0; + LiveAttemptSplitCaptures.Reset(); UpdateDashboardPresentation(); return true; } @@ -349,6 +351,7 @@ void UHyperTwistCoachDashboardWidget::CancelLiveAttemptTimer() LiveAttemptInspectionElapsedMs = 0; LiveAttemptSolveElapsedMs = 0; LiveAttemptDisplayedMs = 0; + LiveAttemptSplitCaptures.Reset(); UpdateDashboardPresentation(); } @@ -380,6 +383,7 @@ FHyperTwistTrainingRunStepResult UHyperTwistCoachDashboardWidget::SubmitLiveTime Attempt.TimingBreakdown.InspectionElapsedMs = FMath::Max(LiveAttemptInspectionElapsedMs, 0); Attempt.TimingBreakdown.RawSolveTimeMs = FMath::Max(LiveAttemptSolveElapsedMs, 0); Attempt.TimingBreakdown.FinalTimeMs = FMath::Max(LiveAttemptSolveElapsedMs, 0); + Attempt.TimingBreakdown.SplitCaptures = LiveAttemptSplitCaptures; Attempt.TimingBreakdown.bDidNotFinish = Result == EHyperTwistTrainingAttemptResult::Timeout || Result == EHyperTwistTrainingAttemptResult::DNF @@ -400,6 +404,43 @@ FHyperTwistTrainingRunStepResult UHyperTwistCoachDashboardWidget::SubmitLiveTime return StepResult; } +bool UHyperTwistCoachDashboardWidget::CaptureNextLiveAttemptSplit() +{ + if (!bHasLiveAttemptTimer || LiveAttemptPhase != EHyperTwistCoachDashboardAttemptPhase::Solving) + { + return false; + } + + const FHyperTwistTrainingSplitPhaseDefinition* NextPhaseDefinition = FindNextSplitPhaseToCapture(); + if (NextPhaseDefinition == nullptr) + { + return false; + } + + RefreshLiveAttemptClock(); + + FHyperTwistTrainingSplitCapture SplitCapture; + SplitCapture.PhaseId = NextPhaseDefinition->PhaseId; + SplitCapture.Label = NextPhaseDefinition->Label; + SplitCapture.TimestampMs = FMath::Max(LiveAttemptSolveElapsedMs, 0); + + LiveAttemptSplitCaptures.Add(SplitCapture); + UpdateDashboardPresentation(); + return true; +} + +bool UHyperTwistCoachDashboardWidget::UndoLastLiveAttemptSplit() +{ + if (LiveAttemptSplitCaptures.Num() <= 0) + { + return false; + } + + LiveAttemptSplitCaptures.RemoveAt(LiveAttemptSplitCaptures.Num() - 1); + UpdateDashboardPresentation(); + return true; +} + void UHyperTwistCoachDashboardWidget::HandlePrimaryActionClicked() { ExecutePrimaryCoachAction(); @@ -553,6 +594,16 @@ void UHyperTwistCoachDashboardWidget::HandleCancelTimerClicked() CancelLiveAttemptTimer(); } +void UHyperTwistCoachDashboardWidget::HandleCaptureSplitClicked() +{ + CaptureNextLiveAttemptSplit(); +} + +void UHyperTwistCoachDashboardWidget::HandleUndoSplitClicked() +{ + UndoLastLiveAttemptSplit(); +} + void UHyperTwistCoachDashboardWidget::HandlePreviousQueueClicked() { SelectPreviousQueueEntry(); @@ -665,6 +716,18 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() WidgetTree, RootLayout, TEXT("CoachTimerStatus"), + 2 + ); + SplitHeaderTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow( + WidgetTree, + RootLayout, + TEXT("CoachSplitHeader"), + 2 + ); + SplitStatusTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow( + WidgetTree, + RootLayout, + TEXT("CoachSplitStatus"), 8 ); FollowUpTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow( @@ -874,6 +937,40 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() CancelTimerButtonLabel = CancelTimerLabelRaw; } + UHorizontalBox* SplitCaptureRow = WidgetTree->ConstructWidget( + UHorizontalBox::StaticClass(), + TEXT("CoachDashboardSplitCaptureRow") + ); + if (SplitCaptureRow != nullptr) + { + if (UVerticalBoxSlot* SplitCaptureRowSlot = RootLayout->AddChildToVerticalBox(SplitCaptureRow)) + { + SplitCaptureRowSlot->SetPadding(FMargin(0.0f, 4.0f, 0.0f, 0.0f)); + } + + UTextBlock* CaptureSplitLabelRaw = nullptr; + CaptureSplitButton = HyperTwistCoachDashboardWidgetInternal::AddButton( + WidgetTree, + SplitCaptureRow, + TEXT("CaptureSplitButton"), + TEXT("CaptureSplitButtonLabel"), + TEXT("Capture Split"), + CaptureSplitLabelRaw + ); + CaptureSplitButtonLabel = CaptureSplitLabelRaw; + + UTextBlock* UndoSplitLabelRaw = nullptr; + UndoSplitButton = HyperTwistCoachDashboardWidgetInternal::AddButton( + WidgetTree, + SplitCaptureRow, + TEXT("UndoSplitButton"), + TEXT("UndoSplitButtonLabel"), + TEXT("Undo Split"), + UndoSplitLabelRaw + ); + UndoSplitButtonLabel = UndoSplitLabelRaw; + } + UHorizontalBox* RunControlRow = WidgetTree->ConstructWidget( UHorizontalBox::StaticClass(), TEXT("CoachDashboardRunControlButtons") @@ -1053,6 +1150,14 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() { CancelTimerButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleCancelTimerClicked); } + if (CaptureSplitButton != nullptr) + { + CaptureSplitButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleCaptureSplitClicked); + } + if (UndoSplitButton != nullptr) + { + UndoSplitButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleUndoSplitClicked); + } if (PreviousQueueButton != nullptr) { PreviousQueueButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandlePreviousQueueClicked); @@ -1147,6 +1252,32 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() const bool bLiveInspectionPhase = IsLiveAttemptInspectionPhase(); const bool bLiveSolvePhase = bHasLiveAttemptTimer && LiveAttemptPhase == EHyperTwistCoachDashboardAttemptPhase::Solving; + const int32 MaxManualSplitCaptureCount = GetMaxManualSplitCaptureCount(); + const FHyperTwistTrainingSplitPhaseDefinition* NextSplitPhaseDefinition = FindNextSplitPhaseToCapture(); + const FString NextSplitLabel = NextSplitPhaseDefinition == nullptr + ? TEXT("none") + : (NextSplitPhaseDefinition->Label.IsEmpty() + ? NextSplitPhaseDefinition->PhaseId + : NextSplitPhaseDefinition->Label); + FString CapturedSplitSummary = TEXT("none"); + if (LiveAttemptSplitCaptures.Num() > 0) + { + TArray CapturedSplitParts; + CapturedSplitParts.Reserve(LiveAttemptSplitCaptures.Num()); + for (const FHyperTwistTrainingSplitCapture& SplitCapture : LiveAttemptSplitCaptures) + { + const FString Label = SplitCapture.Label.IsEmpty() ? SplitCapture.PhaseId : SplitCapture.Label; + CapturedSplitParts.Add(FString::Printf(TEXT("%s@%d"), *Label, SplitCapture.TimestampMs)); + } + CapturedSplitSummary = FString::Join(CapturedSplitParts, TEXT(" | ")); + } + const FString FinalSplitLabel = + TimingPolicy != nullptr + && TimingPolicy->SplitPhases.IsValidIndex(MaxManualSplitCaptureCount) + ? (TimingPolicy->SplitPhases[MaxManualSplitCaptureCount].Label.IsEmpty() + ? TimingPolicy->SplitPhases[MaxManualSplitCaptureCount].PhaseId + : TimingPolicy->SplitPhases[MaxManualSplitCaptureCount].Label) + : TEXT("solve end"); const FString RunningSolveLabel = TimingPolicy != nullptr && TimingPolicy->bHideRunningTime && bLiveSolvePhase ? TEXT("hidden") @@ -1337,6 +1468,48 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() ))); } } + if (SplitHeaderTextBlock != nullptr) + { + SplitHeaderTextBlock->SetText(FText::FromString(TEXT("[Split Capture]"))); + } + if (SplitStatusTextBlock != nullptr) + { + if (MaxManualSplitCaptureCount <= 0) + { + SplitStatusTextBlock->SetText(FText::FromString( + TEXT("Split capture: no manual split phases are configured for this timing policy.") + )); + } + else + { + const FString SplitStateLine = FString::Printf( + TEXT("Manual splits %d/%d | Next: %s | Final auto-close: %s | Captured: %s"), + LiveAttemptSplitCaptures.Num(), + MaxManualSplitCaptureCount, + *NextSplitLabel, + *FinalSplitLabel, + *CapturedSplitSummary + ); + if (bLiveSolvePhase) + { + SplitStatusTextBlock->SetText(FText::FromString( + SplitStateLine + TEXT(" | capture phase ends during solve; final phase closes on attempt stop") + )); + } + else if (bLiveInspectionPhase) + { + SplitStatusTextBlock->SetText(FText::FromString( + SplitStateLine + TEXT(" | split capture unlocks after solve start") + )); + } + else + { + SplitStatusTextBlock->SetText(FText::FromString( + SplitStateLine + TEXT(" | start a live attempt to record manual split boundaries") + )); + } + } + } if (AttemptTimeTextBox != nullptr) { const FString HintLabel = CurrentTrainingCase.TimeTargetMs > 0 @@ -1659,6 +1832,25 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() { CancelTimerButton->SetIsEnabled(bHasLiveAttemptTimer); } + if (CaptureSplitButtonLabel != nullptr) + { + const FString CaptureSplitLabel = NextSplitPhaseDefinition == nullptr + ? TEXT("All Splits Captured") + : FString::Printf(TEXT("Capture %s"), *NextSplitLabel); + CaptureSplitButtonLabel->SetText(FText::FromString(CaptureSplitLabel)); + } + if (CaptureSplitButton != nullptr) + { + CaptureSplitButton->SetIsEnabled(bLiveSolvePhase && NextSplitPhaseDefinition != nullptr); + } + if (UndoSplitButtonLabel != nullptr) + { + UndoSplitButtonLabel->SetText(FText::FromString(TEXT("Undo Split"))); + } + if (UndoSplitButton != nullptr) + { + UndoSplitButton->SetIsEnabled(bLiveSolvePhase && LiveAttemptSplitCaptures.Num() > 0); + } if (PreviousQueueButtonLabel != nullptr) { PreviousQueueButtonLabel->SetText(FText::FromString(TEXT("Prev Queue"))); @@ -1851,3 +2043,34 @@ bool UHyperTwistCoachDashboardWidget::IsLiveAttemptInspectionPhase() const { return bHasLiveAttemptTimer && LiveAttemptPhase == EHyperTwistCoachDashboardAttemptPhase::Inspection; } + +int32 UHyperTwistCoachDashboardWidget::GetMaxManualSplitCaptureCount() const +{ + const FHyperTwistTrainingTimingPolicy* TimingPolicy = FindActiveTimingPolicy(); + if (TimingPolicy == nullptr || TimingPolicy->SplitPhases.Num() <= 1) + { + return 0; + } + + return TimingPolicy->SplitPhases.Num() - 1; +} + +const FHyperTwistTrainingSplitPhaseDefinition* UHyperTwistCoachDashboardWidget::FindNextSplitPhaseToCapture() const +{ + const FHyperTwistTrainingTimingPolicy* TimingPolicy = FindActiveTimingPolicy(); + if (TimingPolicy == nullptr) + { + return nullptr; + } + + const int32 NextPhaseIndex = LiveAttemptSplitCaptures.Num(); + const int32 MaxManualSplitCaptureCount = GetMaxManualSplitCaptureCount(); + if (NextPhaseIndex < 0 + || NextPhaseIndex >= MaxManualSplitCaptureCount + || !TimingPolicy->SplitPhases.IsValidIndex(NextPhaseIndex)) + { + return nullptr; + } + + return &TimingPolicy->SplitPhases[NextPhaseIndex]; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h index d03c06e..1456cec 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h @@ -61,6 +61,9 @@ public: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard") int32 LiveAttemptDisplayedMs = 0; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard") + TArray LiveAttemptSplitCaptures; + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") void RefreshCoachDashboardView(); @@ -102,6 +105,12 @@ public: EHyperTwistTrainingAttemptResult Result ); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") + bool CaptureNextLiveAttemptSplit(); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") + bool UndoLastLiveAttemptSplit(); + protected: UPROPERTY(Transient) TObjectPtr RootLayout = nullptr; @@ -148,6 +157,12 @@ protected: UPROPERTY(Transient) TObjectPtr TimerStatusTextBlock = nullptr; + UPROPERTY(Transient) + TObjectPtr SplitHeaderTextBlock = nullptr; + + UPROPERTY(Transient) + TObjectPtr SplitStatusTextBlock = nullptr; + UPROPERTY(Transient) TObjectPtr AttemptTimeTextBox = nullptr; @@ -271,6 +286,18 @@ protected: UPROPERTY(Transient) TObjectPtr CancelTimerButtonLabel = nullptr; + UPROPERTY(Transient) + TObjectPtr CaptureSplitButton = nullptr; + + UPROPERTY(Transient) + TObjectPtr CaptureSplitButtonLabel = nullptr; + + UPROPERTY(Transient) + TObjectPtr UndoSplitButton = nullptr; + + UPROPERTY(Transient) + TObjectPtr UndoSplitButtonLabel = nullptr; + UPROPERTY(Transient) TObjectPtr PreviousQueueButton = nullptr; @@ -331,6 +358,12 @@ protected: UFUNCTION() void HandleCancelTimerClicked(); + UFUNCTION() + void HandleCaptureSplitClicked(); + + UFUNCTION() + void HandleUndoSplitClicked(); + UFUNCTION() void HandlePreviousQueueClicked(); @@ -353,6 +386,8 @@ private: const FHyperTwistTrainingCoachSessionQueueStateEntry* FindSelectedQueueEntry() const; const FHyperTwistTrainingTimingPolicy* FindActiveTimingPolicy() const; bool IsLiveAttemptInspectionPhase() const; + int32 GetMaxManualSplitCaptureCount() const; + const FHyperTwistTrainingSplitPhaseDefinition* FindNextSplitPhaseToCapture() const; EHyperTwistCoachDashboardAttemptPhase LiveAttemptPhase = EHyperTwistCoachDashboardAttemptPhase::Idle; double LiveAttemptStartedAtSeconds = 0.0;