diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp index df2748b..57240ff 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp @@ -9,6 +9,7 @@ #include "Components/TextBlock.h" #include "Components/VerticalBox.h" #include "Components/VerticalBoxSlot.h" +#include "HAL/PlatformTime.h" namespace HyperTwistCoachDashboardWidgetInternal { @@ -107,9 +108,37 @@ void UHyperTwistCoachDashboardWidget::NativeConstruct() RefreshCoachDashboardView(); } +void UHyperTwistCoachDashboardWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime) +{ + Super::NativeTick(MyGeometry, InDeltaTime); + + if (bRefreshLiveAttemptClockOnTick && bHasLiveAttemptTimer) + { + RefreshLiveAttemptClock(); + UpdateDashboardPresentation(); + } +} + void UHyperTwistCoachDashboardWidget::RefreshCoachDashboardView() { RefreshTrainingState(); + if (bHasLiveAttemptTimer + && (!CachedRunState.Session.IsStructurallyValid() + || !CachedRunState.CurrentSelection.TrainingCase.IsStructurallyValid())) + { + bHasLiveAttemptTimer = false; + LiveAttemptPhase = EHyperTwistCoachDashboardAttemptPhase::Idle; + LiveAttemptStartedAtSeconds = 0.0; + LiveAttemptSolveStartedAtSeconds = 0.0; + LiveAttemptStoredInspectionElapsedMs = 0; + LiveAttemptInspectionElapsedMs = 0; + LiveAttemptSolveElapsedMs = 0; + LiveAttemptDisplayedMs = 0; + } + else + { + RefreshLiveAttemptClock(); + } SyncSelectedQueueEntry(); UpdateDashboardPresentation(); } @@ -264,6 +293,113 @@ FHyperTwistTrainingRunStepResult UHyperTwistCoachDashboardWidget::SubmitSyntheti return SubmitTrainingAttempt(Attempt, TimeMs); } +bool UHyperTwistCoachDashboardWidget::StartLiveAttemptTimer() +{ + if (!CachedRunState.Session.IsStructurallyValid() || !CachedRunState.CurrentSelection.TrainingCase.IsStructurallyValid()) + { + return false; + } + + if (bHasLiveAttemptTimer) + { + return AdvanceLiveAttemptToSolvePhase(); + } + + const FHyperTwistTrainingTimingPolicy* TimingPolicy = FindActiveTimingPolicy(); + const bool bUseInspection = TimingPolicy != nullptr + && TimingPolicy->bInspectionEnabled + && TimingPolicy->InspectionDurationMs > 0; + + bHasLiveAttemptTimer = true; + LiveAttemptPhase = bUseInspection + ? EHyperTwistCoachDashboardAttemptPhase::Inspection + : EHyperTwistCoachDashboardAttemptPhase::Solving; + LiveAttemptStartedAtSeconds = FPlatformTime::Seconds(); + LiveAttemptSolveStartedAtSeconds = bUseInspection ? 0.0 : LiveAttemptStartedAtSeconds; + LiveAttemptStoredInspectionElapsedMs = 0; + LiveAttemptInspectionElapsedMs = 0; + LiveAttemptSolveElapsedMs = 0; + LiveAttemptDisplayedMs = 0; + UpdateDashboardPresentation(); + return true; +} + +bool UHyperTwistCoachDashboardWidget::AdvanceLiveAttemptToSolvePhase() +{ + if (!bHasLiveAttemptTimer || LiveAttemptPhase != EHyperTwistCoachDashboardAttemptPhase::Inspection) + { + return false; + } + + RefreshLiveAttemptClock(); + LiveAttemptStoredInspectionElapsedMs = LiveAttemptInspectionElapsedMs; + LiveAttemptSolveStartedAtSeconds = FPlatformTime::Seconds(); + LiveAttemptPhase = EHyperTwistCoachDashboardAttemptPhase::Solving; + UpdateDashboardPresentation(); + return true; +} + +void UHyperTwistCoachDashboardWidget::CancelLiveAttemptTimer() +{ + bHasLiveAttemptTimer = false; + LiveAttemptPhase = EHyperTwistCoachDashboardAttemptPhase::Idle; + LiveAttemptStartedAtSeconds = 0.0; + LiveAttemptSolveStartedAtSeconds = 0.0; + LiveAttemptStoredInspectionElapsedMs = 0; + LiveAttemptInspectionElapsedMs = 0; + LiveAttemptSolveElapsedMs = 0; + LiveAttemptDisplayedMs = 0; + UpdateDashboardPresentation(); +} + +FHyperTwistTrainingRunStepResult UHyperTwistCoachDashboardWidget::SubmitLiveTimedAttempt( + const EHyperTwistTrainingAttemptResult Result +) +{ + if (!bHasLiveAttemptTimer + || LiveAttemptPhase != EHyperTwistCoachDashboardAttemptPhase::Solving + || !CachedRunState.Session.IsStructurallyValid() + || !CachedRunState.CurrentSelection.TrainingCase.IsStructurallyValid()) + { + return FHyperTwistTrainingRunStepResult(); + } + + RefreshLiveAttemptClock(); + + FHyperTwistTrainingAttempt Attempt; + Attempt.AttemptId = FString::Printf( + TEXT("attempt_%s_%s"), + *CachedRunState.Session.TrainingSessionId, + *FGuid::NewGuid().ToString(EGuidFormats::Digits) + ); + Attempt.TrainingSessionId = CachedRunState.Session.TrainingSessionId; + Attempt.CaseId = CachedRunState.CurrentSelection.TrainingCase.CaseId; + Attempt.Result = Result; + Attempt.TotalTimeMs = FMath::Max(LiveAttemptSolveElapsedMs, 0); + Attempt.ExecutionTimeMs = FMath::Max(LiveAttemptSolveElapsedMs, 0); + Attempt.TimingBreakdown.InspectionElapsedMs = FMath::Max(LiveAttemptInspectionElapsedMs, 0); + Attempt.TimingBreakdown.RawSolveTimeMs = FMath::Max(LiveAttemptSolveElapsedMs, 0); + Attempt.TimingBreakdown.FinalTimeMs = FMath::Max(LiveAttemptSolveElapsedMs, 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(); + + const FHyperTwistTrainingRunStepResult StepResult = SubmitTrainingAttempt( + Attempt, + FMath::Max(LiveAttemptSolveElapsedMs, 0) + ); + CancelLiveAttemptTimer(); + return StepResult; +} + void UHyperTwistCoachDashboardWidget::HandlePrimaryActionClicked() { ExecutePrimaryCoachAction(); @@ -315,40 +451,68 @@ void UHyperTwistCoachDashboardWidget::HandleClearRunClicked() void UHyperTwistCoachDashboardWidget::HandleRecordSuccessClicked() { - SubmitSyntheticCurrentAttempt( - EHyperTwistTrainingAttemptResult::Success, - ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Success) - ); + if (bHasLiveAttemptTimer) + { + SubmitLiveTimedAttempt(EHyperTwistTrainingAttemptResult::Success); + } + else + { + SubmitSyntheticCurrentAttempt( + EHyperTwistTrainingAttemptResult::Success, + ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Success) + ); + } SyncSelectedQueueEntry(); UpdateDashboardPresentation(); } void UHyperTwistCoachDashboardWidget::HandleRecordFailureClicked() { - SubmitSyntheticCurrentAttempt( - EHyperTwistTrainingAttemptResult::Failure, - ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Failure) - ); + if (bHasLiveAttemptTimer) + { + SubmitLiveTimedAttempt(EHyperTwistTrainingAttemptResult::Failure); + } + else + { + SubmitSyntheticCurrentAttempt( + EHyperTwistTrainingAttemptResult::Failure, + ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Failure) + ); + } SyncSelectedQueueEntry(); UpdateDashboardPresentation(); } void UHyperTwistCoachDashboardWidget::HandleRecordTimeoutClicked() { - SubmitSyntheticCurrentAttempt( - EHyperTwistTrainingAttemptResult::Timeout, - ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Timeout) - ); + if (bHasLiveAttemptTimer) + { + SubmitLiveTimedAttempt(EHyperTwistTrainingAttemptResult::Timeout); + } + else + { + SubmitSyntheticCurrentAttempt( + EHyperTwistTrainingAttemptResult::Timeout, + ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Timeout) + ); + } SyncSelectedQueueEntry(); UpdateDashboardPresentation(); } void UHyperTwistCoachDashboardWidget::HandleRecordDnfClicked() { - SubmitSyntheticCurrentAttempt( - EHyperTwistTrainingAttemptResult::DNF, - ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::DNF) - ); + if (bHasLiveAttemptTimer) + { + SubmitLiveTimedAttempt(EHyperTwistTrainingAttemptResult::DNF); + } + else + { + SubmitSyntheticCurrentAttempt( + EHyperTwistTrainingAttemptResult::DNF, + ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::DNF) + ); + } SyncSelectedQueueEntry(); UpdateDashboardPresentation(); } @@ -372,6 +536,23 @@ void UHyperTwistCoachDashboardWidget::HandleUseAverageTimeClicked() UpdateDashboardPresentation(); } +void UHyperTwistCoachDashboardWidget::HandleStartTimerClicked() +{ + if (bHasLiveAttemptTimer && IsLiveAttemptInspectionPhase()) + { + AdvanceLiveAttemptToSolvePhase(); + } + else + { + StartLiveAttemptTimer(); + } +} + +void UHyperTwistCoachDashboardWidget::HandleCancelTimerClicked() +{ + CancelLiveAttemptTimer(); +} + void UHyperTwistCoachDashboardWidget::HandlePreviousQueueClicked() { SelectPreviousQueueEntry(); @@ -472,6 +653,18 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() WidgetTree, RootLayout, TEXT("CoachAttemptInputStatus"), + 2 + ); + TimerHeaderTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow( + WidgetTree, + RootLayout, + TEXT("CoachTimerHeader"), + 2 + ); + TimerStatusTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow( + WidgetTree, + RootLayout, + TEXT("CoachTimerStatus"), 8 ); FollowUpTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow( @@ -647,6 +840,40 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() UseAverageTimeButtonLabel = UseAverageTimeLabelRaw; } + UHorizontalBox* LiveTimerRow = WidgetTree->ConstructWidget( + UHorizontalBox::StaticClass(), + TEXT("CoachDashboardLiveTimerRow") + ); + if (LiveTimerRow != nullptr) + { + if (UVerticalBoxSlot* LiveTimerRowSlot = RootLayout->AddChildToVerticalBox(LiveTimerRow)) + { + LiveTimerRowSlot->SetPadding(FMargin(0.0f, 4.0f, 0.0f, 0.0f)); + } + + UTextBlock* StartTimerLabelRaw = nullptr; + StartTimerButton = HyperTwistCoachDashboardWidgetInternal::AddButton( + WidgetTree, + LiveTimerRow, + TEXT("StartTimerButton"), + TEXT("StartTimerButtonLabel"), + TEXT("Start Attempt"), + StartTimerLabelRaw + ); + StartTimerButtonLabel = StartTimerLabelRaw; + + UTextBlock* CancelTimerLabelRaw = nullptr; + CancelTimerButton = HyperTwistCoachDashboardWidgetInternal::AddButton( + WidgetTree, + LiveTimerRow, + TEXT("CancelTimerButton"), + TEXT("CancelTimerButtonLabel"), + TEXT("Cancel Timer"), + CancelTimerLabelRaw + ); + CancelTimerButtonLabel = CancelTimerLabelRaw; + } + UHorizontalBox* RunControlRow = WidgetTree->ConstructWidget( UHorizontalBox::StaticClass(), TEXT("CoachDashboardRunControlButtons") @@ -818,6 +1045,14 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() { UseAverageTimeButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleUseAverageTimeClicked); } + if (StartTimerButton != nullptr) + { + StartTimerButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleStartTimerClicked); + } + if (CancelTimerButton != nullptr) + { + CancelTimerButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleCancelTimerClicked); + } if (PreviousQueueButton != nullptr) { PreviousQueueButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandlePreviousQueueClicked); @@ -904,6 +1139,29 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() const int32 ResolvedFailureTimeMs = ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Failure); const int32 ResolvedTimeoutTimeMs = ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::Timeout); const int32 ResolvedDnfTimeMs = ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult::DNF); + const FHyperTwistTrainingTimingPolicy* TimingPolicy = FindActiveTimingPolicy(); + const bool bHasInspectionWindow = TimingPolicy != nullptr + && TimingPolicy->bInspectionEnabled + && TimingPolicy->InspectionDurationMs > 0; + const int32 InspectionLimitMs = bHasInspectionWindow ? FMath::Max(TimingPolicy->InspectionDurationMs, 0) : 0; + const bool bLiveInspectionPhase = IsLiveAttemptInspectionPhase(); + const bool bLiveSolvePhase = bHasLiveAttemptTimer + && LiveAttemptPhase == EHyperTwistCoachDashboardAttemptPhase::Solving; + const FString RunningSolveLabel = + TimingPolicy != nullptr && TimingPolicy->bHideRunningTime && bLiveSolvePhase + ? TEXT("hidden") + : FString::FromInt(LiveAttemptSolveElapsedMs); + const EHyperTwistTrainingPenalty LiveInspectionPenalty = + (bHasInspectionWindow + && TimingPolicy->bInspectionPenaltiesEnabled + && LiveAttemptInspectionElapsedMs > InspectionLimitMs + && LiveAttemptInspectionElapsedMs <= InspectionLimitMs + 2000) + ? EHyperTwistTrainingPenalty::Plus2 + : ((bHasInspectionWindow + && TimingPolicy->bInspectionPenaltiesEnabled + && LiveAttemptInspectionElapsedMs > InspectionLimitMs + 2000) + ? EHyperTwistTrainingPenalty::DNF + : EHyperTwistTrainingPenalty::None); const FString VerificationNotePreview = CachedRepositoryRoundTripVerification.VerificationNotes.Num() > 0 ? CachedRepositoryRoundTripVerification.VerificationNotes[0] : TEXT("none"); @@ -1042,13 +1300,50 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() ResolvedDnfTimeMs ))); } + if (TimerHeaderTextBlock != nullptr) + { + TimerHeaderTextBlock->SetText(FText::FromString(TEXT("[Live Timer]"))); + } + if (TimerStatusTextBlock != nullptr) + { + if (bLiveInspectionPhase) + { + const int32 RemainingInspectionMs = bHasInspectionWindow + ? FMath::Max(InspectionLimitMs - LiveAttemptInspectionElapsedMs, 0) + : 0; + TimerStatusTextBlock->SetText(FText::FromString(FString::Printf( + TEXT("Phase: inspection | elapsed %d ms | remaining %d ms | pending penalty: %s | click Start Solve when ready"), + LiveAttemptInspectionElapsedMs, + RemainingInspectionMs, + *HyperTwistCoachDashboardWidgetInternal::EnumDisplayName(LiveInspectionPenalty) + ))); + } + else if (bLiveSolvePhase) + { + TimerStatusTextBlock->SetText(FText::FromString(FString::Printf( + TEXT("Phase: solving | inspection %d ms | solve %s ms | finish with Success / Failure / Timeout / DNF"), + LiveAttemptInspectionElapsedMs, + *RunningSolveLabel + ))); + } + else + { + const FString InspectionLabel = bHasInspectionWindow + ? FString::Printf(TEXT("inspection target %d ms"), InspectionLimitMs) + : TEXT("no inspection window"); + TimerStatusTextBlock->SetText(FText::FromString(FString::Printf( + TEXT("Timer idle | %s | start a live attempt for less-synthetic run entry"), + *InspectionLabel + ))); + } + } 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); + AttemptTimeTextBox->SetIsEnabled(!bHasLiveAttemptTimer && bHasRunnableCurrentCase); } if (FollowUpTextBlock != nullptr) { @@ -1237,9 +1532,12 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() if (PrimaryActionButton != nullptr) { PrimaryActionButton->SetIsEnabled( - CachedCoachPanelState.bCanStartQueuedRun - || CachedCoachPanelState.bCanStartCoachFollowUpRun - || CachedCoachPanelState.bCanStartCoachRecommendedRun + !bHasLiveAttemptTimer + && ( + CachedCoachPanelState.bCanStartQueuedRun + || CachedCoachPanelState.bCanStartCoachFollowUpRun + || CachedCoachPanelState.bCanStartCoachRecommendedRun + ) ); } @@ -1253,7 +1551,7 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() } if (DeferButton != nullptr) { - DeferButton->SetIsEnabled(FindSelectedQueueEntry() != nullptr); + DeferButton->SetIsEnabled(!bHasLiveAttemptTimer && FindSelectedQueueEntry() != nullptr); } if (PromoteButtonLabel != nullptr) { @@ -1261,7 +1559,7 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() } if (PromoteButton != nullptr) { - PromoteButton->SetIsEnabled(!FindFirstDeferredQueueEntryId().IsEmpty()); + PromoteButton->SetIsEnabled(!bHasLiveAttemptTimer && !FindFirstDeferredQueueEntryId().IsEmpty()); } if (CompleteRunButtonLabel != nullptr) { @@ -1269,7 +1567,7 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() } if (CompleteRunButton != nullptr) { - CompleteRunButton->SetIsEnabled(bHasActiveRun); + CompleteRunButton->SetIsEnabled(bHasActiveRun && !bHasLiveAttemptTimer); } if (ClearRunButtonLabel != nullptr) { @@ -1277,39 +1575,47 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() } if (ClearRunButton != nullptr) { - ClearRunButton->SetIsEnabled(bHasActiveRun); + ClearRunButton->SetIsEnabled(bHasActiveRun && !bHasLiveAttemptTimer); } if (RecordSuccessButtonLabel != nullptr) { - RecordSuccessButtonLabel->SetText(FText::FromString(TEXT("Record Success"))); + RecordSuccessButtonLabel->SetText(FText::FromString( + bLiveSolvePhase ? TEXT("Stop Success") : TEXT("Record Success") + )); } if (RecordSuccessButton != nullptr) { - RecordSuccessButton->SetIsEnabled(bHasRunnableCurrentCase); + RecordSuccessButton->SetIsEnabled(bLiveSolvePhase || (!bHasLiveAttemptTimer && bHasRunnableCurrentCase)); } if (RecordFailureButtonLabel != nullptr) { - RecordFailureButtonLabel->SetText(FText::FromString(TEXT("Record Failure"))); + RecordFailureButtonLabel->SetText(FText::FromString( + bLiveSolvePhase ? TEXT("Stop Failure") : TEXT("Record Failure") + )); } if (RecordFailureButton != nullptr) { - RecordFailureButton->SetIsEnabled(bHasRunnableCurrentCase); + RecordFailureButton->SetIsEnabled(bLiveSolvePhase || (!bHasLiveAttemptTimer && bHasRunnableCurrentCase)); } if (RecordTimeoutButtonLabel != nullptr) { - RecordTimeoutButtonLabel->SetText(FText::FromString(TEXT("Record Timeout"))); + RecordTimeoutButtonLabel->SetText(FText::FromString( + bLiveSolvePhase ? TEXT("Stop Timeout") : TEXT("Record Timeout") + )); } if (RecordTimeoutButton != nullptr) { - RecordTimeoutButton->SetIsEnabled(bHasRunnableCurrentCase); + RecordTimeoutButton->SetIsEnabled(bLiveSolvePhase || (!bHasLiveAttemptTimer && bHasRunnableCurrentCase)); } if (RecordDnfButtonLabel != nullptr) { - RecordDnfButtonLabel->SetText(FText::FromString(TEXT("Record DNF"))); + RecordDnfButtonLabel->SetText(FText::FromString( + bLiveSolvePhase ? TEXT("Stop DNF") : TEXT("Record DNF") + )); } if (RecordDnfButton != nullptr) { - RecordDnfButton->SetIsEnabled(bHasRunnableCurrentCase); + RecordDnfButton->SetIsEnabled(bLiveSolvePhase || (!bHasLiveAttemptTimer && bHasRunnableCurrentCase)); } if (UseTargetTimeButtonLabel != nullptr) { @@ -1317,7 +1623,9 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() } if (UseTargetTimeButton != nullptr) { - UseTargetTimeButton->SetIsEnabled(bHasRunnableCurrentCase && CurrentTrainingCase.TimeTargetMs > 0); + UseTargetTimeButton->SetIsEnabled( + !bHasLiveAttemptTimer && bHasRunnableCurrentCase && CurrentTrainingCase.TimeTargetMs > 0 + ); } if (UseAverageTimeButtonLabel != nullptr) { @@ -1325,7 +1633,31 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() } if (UseAverageTimeButton != nullptr) { - UseAverageTimeButton->SetIsEnabled(bHasActiveRun && CachedRunSummary.AverageTotalTimeMs > 0); + UseAverageTimeButton->SetIsEnabled( + !bHasLiveAttemptTimer && bHasActiveRun && CachedRunSummary.AverageTotalTimeMs > 0 + ); + } + if (StartTimerButtonLabel != nullptr) + { + StartTimerButtonLabel->SetText(FText::FromString( + bLiveInspectionPhase ? TEXT("Start Solve") + : (bHasLiveAttemptTimer ? TEXT("Timer Active") : TEXT("Start Attempt")) + )); + } + if (StartTimerButton != nullptr) + { + StartTimerButton->SetIsEnabled( + bHasRunnableCurrentCase + && (!bHasLiveAttemptTimer || bLiveInspectionPhase) + ); + } + if (CancelTimerButtonLabel != nullptr) + { + CancelTimerButtonLabel->SetText(FText::FromString(TEXT("Cancel Timer"))); + } + if (CancelTimerButton != nullptr) + { + CancelTimerButton->SetIsEnabled(bHasLiveAttemptTimer); } if (PreviousQueueButtonLabel != nullptr) { @@ -1333,7 +1665,7 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() } if (PreviousQueueButton != nullptr) { - PreviousQueueButton->SetIsEnabled(CachedCoachSessionQueueState.Entries.Num() > 1); + PreviousQueueButton->SetIsEnabled(!bHasLiveAttemptTimer && CachedCoachSessionQueueState.Entries.Num() > 1); } if (StartSelectedButtonLabel != nullptr) { @@ -1346,7 +1678,7 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() && (SelectedEntry->EntryState == EHyperTwistTrainingCoachSessionQueueEntryState::Ready || (SelectedEntry->EntryState == EHyperTwistTrainingCoachSessionQueueEntryState::Pending && SelectedEntry->bCanStartImmediately)); - StartSelectedButton->SetIsEnabled(bSelectedRunnable); + StartSelectedButton->SetIsEnabled(!bHasLiveAttemptTimer && bSelectedRunnable); } if (NextQueueButtonLabel != nullptr) { @@ -1354,7 +1686,38 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() } if (NextQueueButton != nullptr) { - NextQueueButton->SetIsEnabled(CachedCoachSessionQueueState.Entries.Num() > 1); + NextQueueButton->SetIsEnabled(!bHasLiveAttemptTimer && CachedCoachSessionQueueState.Entries.Num() > 1); + } +} + +void UHyperTwistCoachDashboardWidget::RefreshLiveAttemptClock() +{ + if (!bHasLiveAttemptTimer) + { + LiveAttemptInspectionElapsedMs = 0; + LiveAttemptSolveElapsedMs = 0; + LiveAttemptDisplayedMs = 0; + return; + } + + const double NowSeconds = FPlatformTime::Seconds(); + if (LiveAttemptPhase == EHyperTwistCoachDashboardAttemptPhase::Inspection) + { + LiveAttemptInspectionElapsedMs = FMath::Max( + 0, + static_cast((NowSeconds - LiveAttemptStartedAtSeconds) * 1000.0) + ); + LiveAttemptSolveElapsedMs = 0; + LiveAttemptDisplayedMs = LiveAttemptInspectionElapsedMs; + } + else if (LiveAttemptPhase == EHyperTwistCoachDashboardAttemptPhase::Solving) + { + LiveAttemptInspectionElapsedMs = FMath::Max(LiveAttemptStoredInspectionElapsedMs, 0); + LiveAttemptSolveElapsedMs = FMath::Max( + 0, + static_cast((NowSeconds - LiveAttemptSolveStartedAtSeconds) * 1000.0) + ); + LiveAttemptDisplayedMs = LiveAttemptSolveElapsedMs; } } @@ -1473,3 +1836,18 @@ const FHyperTwistTrainingCoachSessionQueueStateEntry* UHyperTwistCoachDashboardW return &CachedCoachSessionQueueState.Entries[SelectedQueueEntryIndex]; } + +const FHyperTwistTrainingTimingPolicy* UHyperTwistCoachDashboardWidget::FindActiveTimingPolicy() const +{ + if (!CachedRunState.Session.IsStructurallyValid() || !CachedRunState.ActiveDeck.IsStructurallyValid()) + { + return nullptr; + } + + return &CachedRunState.ActiveDeck.TimingPolicy; +} + +bool UHyperTwistCoachDashboardWidget::IsLiveAttemptInspectionPhase() const +{ + return bHasLiveAttemptTimer && LiveAttemptPhase == EHyperTwistCoachDashboardAttemptPhase::Inspection; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h index 37f5463..d03c06e 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h @@ -9,6 +9,13 @@ class UEditableTextBox; class UTextBlock; class UVerticalBox; +enum class EHyperTwistCoachDashboardAttemptPhase : uint8 +{ + Idle, + Inspection, + Solving +}; + UCLASS(BlueprintType, Blueprintable) class UNREALHYPERTWIST_API UHyperTwistCoachDashboardWidget : public UHyperTwistTrainingPanelWidget { @@ -16,6 +23,7 @@ class UNREALHYPERTWIST_API UHyperTwistCoachDashboardWidget : public UHyperTwistT public: virtual void NativeConstruct() override; + virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Coach|Dashboard") int32 DefaultDeferredHours = 24; @@ -35,9 +43,24 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Coach|Dashboard") bool bPreferActiveQueueSelectionOnRefresh = true; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Coach|Dashboard") + bool bRefreshLiveAttemptClockOnTick = true; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard") int32 SelectedQueueEntryIndex = INDEX_NONE; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard") + bool bHasLiveAttemptTimer = false; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard") + int32 LiveAttemptInspectionElapsedMs = 0; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard") + int32 LiveAttemptSolveElapsedMs = 0; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard") + int32 LiveAttemptDisplayedMs = 0; + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") void RefreshCoachDashboardView(); @@ -65,6 +88,20 @@ public: int32 TimeMs ); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") + bool StartLiveAttemptTimer(); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") + bool AdvanceLiveAttemptToSolvePhase(); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") + void CancelLiveAttemptTimer(); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") + FHyperTwistTrainingRunStepResult SubmitLiveTimedAttempt( + EHyperTwistTrainingAttemptResult Result + ); + protected: UPROPERTY(Transient) TObjectPtr RootLayout = nullptr; @@ -105,6 +142,12 @@ protected: UPROPERTY(Transient) TObjectPtr AttemptInputStatusTextBlock = nullptr; + UPROPERTY(Transient) + TObjectPtr TimerHeaderTextBlock = nullptr; + + UPROPERTY(Transient) + TObjectPtr TimerStatusTextBlock = nullptr; + UPROPERTY(Transient) TObjectPtr AttemptTimeTextBox = nullptr; @@ -216,6 +259,18 @@ protected: UPROPERTY(Transient) TObjectPtr UseAverageTimeButtonLabel = nullptr; + UPROPERTY(Transient) + TObjectPtr StartTimerButton = nullptr; + + UPROPERTY(Transient) + TObjectPtr StartTimerButtonLabel = nullptr; + + UPROPERTY(Transient) + TObjectPtr CancelTimerButton = nullptr; + + UPROPERTY(Transient) + TObjectPtr CancelTimerButtonLabel = nullptr; + UPROPERTY(Transient) TObjectPtr PreviousQueueButton = nullptr; @@ -270,6 +325,12 @@ protected: UFUNCTION() void HandleUseAverageTimeClicked(); + UFUNCTION() + void HandleStartTimerClicked(); + + UFUNCTION() + void HandleCancelTimerClicked(); + UFUNCTION() void HandlePreviousQueueClicked(); @@ -283,10 +344,18 @@ private: void EnsureDefaultDashboardBuilt(); void SyncSelectedQueueEntry(); void UpdateDashboardPresentation(); + void RefreshLiveAttemptClock(); FString BuildDefaultDeferredUntilUtc() const; FString FindFirstDeferredQueueEntryId() const; bool TryParseAttemptTimeOverride(int32& OutTimeMs) const; int32 ResolveAttemptTimeMs(EHyperTwistTrainingAttemptResult Result) const; void SetAttemptTimeText(int32 TimeMs); const FHyperTwistTrainingCoachSessionQueueStateEntry* FindSelectedQueueEntry() const; + const FHyperTwistTrainingTimingPolicy* FindActiveTimingPolicy() const; + bool IsLiveAttemptInspectionPhase() const; + + EHyperTwistCoachDashboardAttemptPhase LiveAttemptPhase = EHyperTwistCoachDashboardAttemptPhase::Idle; + double LiveAttemptStartedAtSeconds = 0.0; + double LiveAttemptSolveStartedAtSeconds = 0.0; + int32 LiveAttemptStoredInspectionElapsedMs = 0; };