From bcd1f9ff4eeb0e80f4dfb5c2488d4bbf9a551023 Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Sun, 17 May 2026 18:56:55 +0200 Subject: [PATCH] Add Bound 2 viewer timeline transport --- .../HyperTwistPuzzleViewerComponent.cpp | 534 ++++++++++++++++-- .../HyperTwistPuzzleViewerComponent.h | 83 ++- .../HyperTwistViewerTypes.h | 158 ++++++ .../HyperTwistTwistyBound2TimelineTest.cpp | 273 +++++++++ 4 files changed, 1004 insertions(+), 44 deletions(-) create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistTwistyBound2TimelineTest.cpp diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp index 665e969..11b25b0 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.cpp @@ -102,6 +102,48 @@ namespace HyperTwistPuzzleViewerComponentInternal return Result; } + TArray BuildMoveBoundaryTimesMs(const TArray& MoveList) + { + TArray Result; + Result.Reserve(MoveList.Num()); + + int32 PreviousBoundaryTimeMs = 0; + for (int32 MoveIndex = 0; MoveIndex < MoveList.Num(); ++MoveIndex) + { + const FHyperTwistViewerMoveEntry& Entry = MoveList[MoveIndex]; + int32 BoundaryTimeMs = 0; + if (Entry.HintPositionSeconds > 0.0f) + { + BoundaryTimeMs = FMath::RoundToInt(Entry.HintPositionSeconds * 1000.0f); + } + else + { + BoundaryTimeMs = (MoveIndex + 1) * 1000; + } + + BoundaryTimeMs = FMath::Max(BoundaryTimeMs, PreviousBoundaryTimeMs); + Result.Add(BoundaryTimeMs); + PreviousBoundaryTimeMs = BoundaryTimeMs; + } + + return Result; + } + + int32 ResolvePlaybackTerminalTimeMs( + const TArray& MoveBoundaryTimesMs, + const FHyperTwistDerivedReplaySummary& ReplaySummary, + const bool bReplayLoaded + ) + { + const int32 LastMoveBoundaryTimeMs = MoveBoundaryTimesMs.Num() > 0 ? MoveBoundaryTimesMs.Last() : 0; + if (bReplayLoaded) + { + return FMath::Max(ReplaySummary.TotalTimeMs, LastMoveBoundaryTimeMs); + } + + return LastMoveBoundaryTimeMs; + } + bool TryBuildSceneContextFromReplay( const FHyperTwistReplayPacket& ReplayPacket, FHyperTwistSimulationSceneContext& OutContext @@ -161,6 +203,230 @@ namespace HyperTwistPuzzleViewerComponentInternal return FMath::Max(0, ReplaySummary.TotalTimeMs); } + + int32 FindLastCompletedMoveIndexForTime(const TArray& MoveBoundaryTimesMs, const int32 CurrentTimeMs) + { + int32 LastCompletedMoveIndex = INDEX_NONE; + for (int32 MoveIndex = 0; MoveIndex < MoveBoundaryTimesMs.Num(); ++MoveIndex) + { + if (MoveBoundaryTimesMs[MoveIndex] <= CurrentTimeMs) + { + LastCompletedMoveIndex = MoveIndex; + } + else + { + break; + } + } + + return LastCompletedMoveIndex; + } + + int32 FindDisplayMoveIndexForTime( + const TArray& MoveBoundaryTimesMs, + const int32 CurrentTimeMs, + const int32 TerminalTimeMs + ) + { + if (MoveBoundaryTimesMs.Num() == 0) + { + return INDEX_NONE; + } + + if (CurrentTimeMs <= 0) + { + return 0; + } + + if (TerminalTimeMs > 0 && CurrentTimeMs >= TerminalTimeMs) + { + return MoveBoundaryTimesMs.Num() - 1; + } + + for (int32 MoveIndex = 0; MoveIndex < MoveBoundaryTimesMs.Num(); ++MoveIndex) + { + if (CurrentTimeMs < MoveBoundaryTimesMs[MoveIndex]) + { + return MoveIndex; + } + + if (CurrentTimeMs == MoveBoundaryTimesMs[MoveIndex] && MoveIndex + 1 < MoveBoundaryTimesMs.Num()) + { + return MoveIndex + 1; + } + } + + return MoveBoundaryTimesMs.Num() - 1; + } + + int32 ResolveNextMoveBoundaryTimeMs(const TArray& MoveBoundaryTimesMs, const int32 CurrentTimeMs) + { + for (const int32 MoveBoundaryTimeMs : MoveBoundaryTimesMs) + { + if (MoveBoundaryTimeMs > CurrentTimeMs) + { + return MoveBoundaryTimeMs; + } + } + + return CurrentTimeMs; + } + + int32 ResolvePreviousMoveBoundaryTimeMs(const TArray& MoveBoundaryTimesMs, const int32 CurrentTimeMs) + { + int32 PreviousBoundaryTimeMs = 0; + for (const int32 MoveBoundaryTimeMs : MoveBoundaryTimesMs) + { + if (MoveBoundaryTimeMs >= CurrentTimeMs) + { + break; + } + + PreviousBoundaryTimeMs = MoveBoundaryTimeMs; + } + + return PreviousBoundaryTimeMs; + } + + FHyperTwistViewerPositionSnapshot BuildPositionSnapshot( + const TArray& MoveList, + const TArray& MoveBoundaryTimesMs, + const int32 CurrentTimeMs, + const int32 TerminalTimeMs, + const EHyperTwistViewerPlaybackDirection PlaybackDirection + ) + { + FHyperTwistViewerPositionSnapshot Snapshot; + Snapshot.CurrentTimeMs = FMath::Max(0, CurrentTimeMs); + Snapshot.bAtStart = Snapshot.CurrentTimeMs <= 0; + Snapshot.bAtEnd = TerminalTimeMs > 0 ? Snapshot.CurrentTimeMs >= TerminalTimeMs : MoveList.Num() == 0; + + if (MoveList.Num() == 0) + { + Snapshot.bAtBoundary = true; + return Snapshot; + } + + Snapshot.BaseCompletedMoveIndex = FindLastCompletedMoveIndexForTime(MoveBoundaryTimesMs, Snapshot.CurrentTimeMs); + Snapshot.BaseBoundaryTimeMs = Snapshot.BaseCompletedMoveIndex != INDEX_NONE + ? MoveBoundaryTimesMs[Snapshot.BaseCompletedMoveIndex] + : 0; + + const int32 NextMoveIndex = Snapshot.BaseCompletedMoveIndex + 1; + if (!Snapshot.bAtEnd + && NextMoveIndex >= 0 + && MoveList.IsValidIndex(NextMoveIndex) + && MoveBoundaryTimesMs.IsValidIndex(NextMoveIndex)) + { + const int32 SegmentStartTimeMs = Snapshot.BaseBoundaryTimeMs; + const int32 SegmentEndTimeMs = MoveBoundaryTimesMs[NextMoveIndex]; + if (Snapshot.CurrentTimeMs > SegmentStartTimeMs && Snapshot.CurrentTimeMs < SegmentEndTimeMs) + { + FHyperTwistViewerInFlightMoveProgress Progress; + Progress.MoveIndex = NextMoveIndex; + Progress.MoveEntry = MoveList[NextMoveIndex]; + Progress.Direction = PlaybackDirection == EHyperTwistViewerPlaybackDirection::None + ? EHyperTwistViewerPlaybackDirection::Forward + : PlaybackDirection; + Progress.StartedAtMs = SegmentStartTimeMs; + Progress.EndsAtMs = SegmentEndTimeMs; + + const int32 SegmentDurationMs = FMath::Max(1, SegmentEndTimeMs - SegmentStartTimeMs); + Progress.Fraction = FMath::Clamp( + static_cast(Snapshot.CurrentTimeMs - SegmentStartTimeMs) / static_cast(SegmentDurationMs), + 0.0f, + 1.0f + ); + + Snapshot.InFlightMoves.Add(Progress); + } + } + + Snapshot.bAtBoundary = Snapshot.InFlightMoves.Num() == 0; + return Snapshot; + } + + FHyperTwistViewerDirectionState BuildDirectionState( + const EHyperTwistViewerPlaybackMode Mode, + const EHyperTwistViewerPlaybackDirection Direction + ) + { + FHyperTwistViewerDirectionState State; + State.Mode = Mode; + State.Direction = Direction; + State.bIsContinuousPlayback = Mode == EHyperTwistViewerPlaybackMode::Playing; + State.bIsPaused = Mode == EHyperTwistViewerPlaybackMode::Paused; + State.bIsStepping = Mode == EHyperTwistViewerPlaybackMode::Stepping; + return State; + } + + bool ArePlaybackStatesEquivalent( + const FHyperTwistViewerPlaybackState& Left, + const FHyperTwistViewerPlaybackState& Right + ) + { + return Left.Mode == Right.Mode + && Left.Direction == Right.Direction + && Left.CurrentMoveIndex == Right.CurrentMoveIndex + && Left.TotalMoveCount == Right.TotalMoveCount + && Left.CurrentTimeMs == Right.CurrentTimeMs + && Left.TotalTimeMs == Right.TotalTimeMs + && Left.InspectionTimeMs == Right.InspectionTimeMs + && Left.bAtStart == Right.bAtStart + && Left.bAtEnd == Right.bAtEnd + && Left.bSceneContextLoaded == Right.bSceneContextLoaded + && Left.bReplayLoaded == Right.bReplayLoaded + && Left.ReplayId == Right.ReplayId + && Left.PuzzleId == Right.PuzzleId + && Left.ReplayResult == Right.ReplayResult + && Left.StatusLabel == Right.StatusLabel; + } + + bool ArePositionSnapshotsEquivalent( + const FHyperTwistViewerPositionSnapshot& Left, + const FHyperTwistViewerPositionSnapshot& Right + ) + { + if (Left.CurrentTimeMs != Right.CurrentTimeMs + || Left.BaseCompletedMoveIndex != Right.BaseCompletedMoveIndex + || Left.BaseBoundaryTimeMs != Right.BaseBoundaryTimeMs + || Left.bAtBoundary != Right.bAtBoundary + || Left.bAtStart != Right.bAtStart + || Left.bAtEnd != Right.bAtEnd + || Left.InFlightMoves.Num() != Right.InFlightMoves.Num()) + { + return false; + } + + for (int32 ProgressIndex = 0; ProgressIndex < Left.InFlightMoves.Num(); ++ProgressIndex) + { + const FHyperTwistViewerInFlightMoveProgress& LeftProgress = Left.InFlightMoves[ProgressIndex]; + const FHyperTwistViewerInFlightMoveProgress& RightProgress = Right.InFlightMoves[ProgressIndex]; + if (LeftProgress.MoveIndex != RightProgress.MoveIndex + || LeftProgress.Direction != RightProgress.Direction + || LeftProgress.StartedAtMs != RightProgress.StartedAtMs + || LeftProgress.EndsAtMs != RightProgress.EndsAtMs + || !FMath::IsNearlyEqual(LeftProgress.Fraction, RightProgress.Fraction) + || LeftProgress.MoveEntry.DisplayLabel != RightProgress.MoveEntry.DisplayLabel) + { + return false; + } + } + + return true; + } + + bool AreDirectionStatesEquivalent( + const FHyperTwistViewerDirectionState& Left, + const FHyperTwistViewerDirectionState& Right + ) + { + return Left.Mode == Right.Mode + && Left.Direction == Right.Direction + && Left.bIsContinuousPlayback == Right.bIsContinuousPlayback + && Left.bIsPaused == Right.bIsPaused + && Left.bIsStepping == Right.bIsStepping; + } } UHyperTwistPuzzleViewerComponent::UHyperTwistPuzzleViewerComponent() @@ -169,6 +435,7 @@ UHyperTwistPuzzleViewerComponent::UHyperTwistPuzzleViewerComponent() // Initialise playback state to a clean stopped-empty state. PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped; + PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::None; PlaybackState.CurrentMoveIndex = INDEX_NONE; PlaybackState.TotalMoveCount = 0; PlaybackState.CurrentTimeMs = 0; @@ -184,7 +451,7 @@ UHyperTwistPuzzleViewerComponent::UHyperTwistPuzzleViewerComponent() void UHyperTwistPuzzleViewerComponent::BeginPlay() { Super::BeginPlay(); - // Nothing to drive on BeginPlay in Bound 1 — tick and animation are Bound 2. + // Host-driven deterministic transport remains explicit in Bound 2. } // --------------------------------------------------------------------------- @@ -193,29 +460,37 @@ void UHyperTwistPuzzleViewerComponent::BeginPlay() void UHyperTwistPuzzleViewerComponent::LoadSceneContext(const FHyperTwistSimulationSceneContext& InContext) { + const FHyperTwistViewerPlaybackState PreviousPlaybackState = PlaybackState; + const FHyperTwistViewerPositionSnapshot PreviousPositionSnapshot = PositionSnapshot; + const FHyperTwistViewerDirectionState PreviousDirectionState = DirectionState; + SceneContext = InContext; PlaybackState.bSceneContextLoaded = InContext.IsStructurallyValid(); // Reset playhead — loaded content has changed. PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped; - PlaybackState.CurrentMoveIndex = MoveList.Num() > 0 ? 0 : INDEX_NONE; - PlaybackState.TotalMoveCount = MoveList.Num(); + PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::None; + PlaybackState.CurrentTimeMs = 0; - RebuildAndBroadcast(); + RebuildAndBroadcast(PreviousPlaybackState, PreviousPositionSnapshot, PreviousDirectionState); } void UHyperTwistPuzzleViewerComponent::LoadMoveList(const TArray& InMoveList) { + const FHyperTwistViewerPlaybackState PreviousPlaybackState = PlaybackState; + const FHyperTwistViewerPositionSnapshot PreviousPositionSnapshot = PositionSnapshot; + const FHyperTwistViewerDirectionState PreviousDirectionState = DirectionState; + MoveList = InMoveList; ReplayPacket = FHyperTwistReplayPacket(); ReplaySummary = FHyperTwistDerivedReplaySummary(); bReplayPacketLoaded = false; PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped; - PlaybackState.TotalMoveCount = MoveList.Num(); - PlaybackState.CurrentMoveIndex = MoveList.Num() > 0 ? 0 : INDEX_NONE; + PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::None; + PlaybackState.CurrentTimeMs = 0; - RebuildAndBroadcast(); + RebuildAndBroadcast(PreviousPlaybackState, PreviousPositionSnapshot, PreviousDirectionState); } bool UHyperTwistPuzzleViewerComponent::LoadReplayPacket(const FHyperTwistReplayPacket& InReplayPacket) @@ -225,6 +500,10 @@ bool UHyperTwistPuzzleViewerComponent::LoadReplayPacket(const FHyperTwistReplayP return false; } + const FHyperTwistViewerPlaybackState PreviousPlaybackState = PlaybackState; + const FHyperTwistViewerPositionSnapshot PreviousPositionSnapshot = PositionSnapshot; + const FHyperTwistViewerDirectionState PreviousDirectionState = DirectionState; + ReplayPacket = InReplayPacket; ReplaySummary = UHyperTwistReplayLibrary::DeriveReplaySummary(InReplayPacket); bReplayPacketLoaded = true; @@ -242,10 +521,10 @@ bool UHyperTwistPuzzleViewerComponent::LoadReplayPacket(const FHyperTwistReplayP } PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped; - PlaybackState.TotalMoveCount = MoveList.Num(); - PlaybackState.CurrentMoveIndex = MoveList.Num() > 0 ? 0 : INDEX_NONE; + PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::None; + PlaybackState.CurrentTimeMs = 0; - RebuildAndBroadcast(); + RebuildAndBroadcast(PreviousPlaybackState, PreviousPositionSnapshot, PreviousDirectionState); return true; } @@ -260,21 +539,42 @@ void UHyperTwistPuzzleViewerComponent::ApplyControlRequest(const FHyperTwistView return; } - const int32 LastIndex = MoveList.Num() - 1; - const bool bHasMoves = MoveList.Num() > 0; + const FHyperTwistViewerPlaybackState PreviousPlaybackState = PlaybackState; + const FHyperTwistViewerPositionSnapshot PreviousPositionSnapshot = PositionSnapshot; + const FHyperTwistViewerDirectionState PreviousDirectionState = DirectionState; + const int32 PreviousTimeMs = PlaybackState.CurrentTimeMs; + const int32 PreviousMoveIndex = PlaybackState.CurrentMoveIndex; + const bool bHasMoves = MoveList.Num() > 0; + const auto ResolveJumpMoveIndex = [this](const int32 NewTimeMs) + { + return HyperTwistPuzzleViewerComponentInternal::FindDisplayMoveIndexForTime( + MoveBoundaryTimesMs, + NewTimeMs, + PlaybackState.TotalTimeMs + ); + }; + FHyperTwistViewerJumpEvent JumpEvent; + FHyperTwistViewerJumpEvent* JumpEventPtr = nullptr; switch (Request.Action) { case EHyperTwistViewerControlAction::Play: - // Only allow play when there are moves and we are not already at the end. if (bHasMoves && PlaybackState.Mode != EHyperTwistViewerPlaybackMode::Playing) { - // If stopped at end, wrap back to start before playing. - if (PlaybackState.bAtEnd && PlaybackState.Mode == EHyperTwistViewerPlaybackMode::Stopped) + if (PlaybackState.TotalTimeMs > 0 + && PlaybackState.CurrentTimeMs >= PlaybackState.TotalTimeMs + && PlaybackState.Mode != EHyperTwistViewerPlaybackMode::Playing) { - PlaybackState.CurrentMoveIndex = 0; + PlaybackState.CurrentTimeMs = 0; + JumpEvent.JumpKind = EHyperTwistViewerJumpKind::PlayWrap; + JumpEvent.PreviousTimeMs = PreviousTimeMs; + JumpEvent.NewTimeMs = 0; + JumpEvent.PreviousMoveIndex = PreviousMoveIndex; + JumpEvent.NewMoveIndex = ResolveJumpMoveIndex(0); + JumpEventPtr = &JumpEvent; } PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Playing; + PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::Forward; } break; @@ -287,18 +587,38 @@ void UHyperTwistPuzzleViewerComponent::ApplyControlRequest(const FHyperTwistView case EHyperTwistViewerControlAction::Stop: PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped; - PlaybackState.CurrentMoveIndex = bHasMoves ? 0 : INDEX_NONE; + PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::None; + PlaybackState.CurrentTimeMs = 0; + if (PreviousTimeMs != 0) + { + JumpEvent.JumpKind = EHyperTwistViewerJumpKind::Stop; + JumpEvent.PreviousTimeMs = PreviousTimeMs; + JumpEvent.NewTimeMs = 0; + JumpEvent.PreviousMoveIndex = PreviousMoveIndex; + JumpEvent.NewMoveIndex = bHasMoves ? 0 : INDEX_NONE; + JumpEventPtr = &JumpEvent; + } break; case EHyperTwistViewerControlAction::StepForward: if (bHasMoves) { PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping; - if (PlaybackState.CurrentMoveIndex < LastIndex) + PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::Forward; + const int32 NewTimeMs = HyperTwistPuzzleViewerComponentInternal::ResolveNextMoveBoundaryTimeMs( + MoveBoundaryTimesMs, + PlaybackState.CurrentTimeMs + ); + if (NewTimeMs != PlaybackState.CurrentTimeMs) { - PlaybackState.CurrentMoveIndex++; + PlaybackState.CurrentTimeMs = NewTimeMs; + JumpEvent.JumpKind = EHyperTwistViewerJumpKind::StepForward; + JumpEvent.PreviousTimeMs = PreviousTimeMs; + JumpEvent.NewTimeMs = NewTimeMs; + JumpEvent.PreviousMoveIndex = PreviousMoveIndex; + JumpEvent.NewMoveIndex = ResolveJumpMoveIndex(NewTimeMs); + JumpEventPtr = &JumpEvent; } - // At end — remain at last index, mode stays Stepping (caller can check bAtEnd). } break; @@ -306,9 +626,20 @@ void UHyperTwistPuzzleViewerComponent::ApplyControlRequest(const FHyperTwistView if (bHasMoves) { PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping; - if (PlaybackState.CurrentMoveIndex > 0) + PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::Backward; + const int32 NewTimeMs = HyperTwistPuzzleViewerComponentInternal::ResolvePreviousMoveBoundaryTimeMs( + MoveBoundaryTimesMs, + PlaybackState.CurrentTimeMs + ); + if (NewTimeMs != PlaybackState.CurrentTimeMs) { - PlaybackState.CurrentMoveIndex--; + PlaybackState.CurrentTimeMs = NewTimeMs; + JumpEvent.JumpKind = EHyperTwistViewerJumpKind::StepBack; + JumpEvent.PreviousTimeMs = PreviousTimeMs; + JumpEvent.NewTimeMs = NewTimeMs; + JumpEvent.PreviousMoveIndex = PreviousMoveIndex; + JumpEvent.NewMoveIndex = ResolveJumpMoveIndex(NewTimeMs); + JumpEventPtr = &JumpEvent; } } break; @@ -317,10 +648,25 @@ void UHyperTwistPuzzleViewerComponent::ApplyControlRequest(const FHyperTwistView { if (bHasMoves) { - // Clamp to valid range. - const int32 ClampedIndex = FMath::Clamp(Request.SeekTargetIndex, 0, LastIndex); - PlaybackState.CurrentMoveIndex = ClampedIndex; - PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping; + const int32 NewTimeMs = ResolveSeekTargetTimeMs(Request.SeekTargetIndex); + if (NewTimeMs > PlaybackState.CurrentTimeMs) + { + PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::Forward; + } + else if (NewTimeMs < PlaybackState.CurrentTimeMs) + { + PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::Backward; + } + + PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping; + PlaybackState.CurrentTimeMs = NewTimeMs; + + JumpEvent.JumpKind = EHyperTwistViewerJumpKind::Seek; + JumpEvent.PreviousTimeMs = PreviousTimeMs; + JumpEvent.NewTimeMs = NewTimeMs; + JumpEvent.PreviousMoveIndex = PreviousMoveIndex; + JumpEvent.NewMoveIndex = ResolveJumpMoveIndex(NewTimeMs); + JumpEventPtr = &JumpEvent; } break; } @@ -329,7 +675,35 @@ void UHyperTwistPuzzleViewerComponent::ApplyControlRequest(const FHyperTwistView break; } - RebuildAndBroadcast(); + RebuildAndBroadcast(PreviousPlaybackState, PreviousPositionSnapshot, PreviousDirectionState, JumpEventPtr); +} + +bool UHyperTwistPuzzleViewerComponent::AdvancePlaybackByMs(const int32 DeltaTimeMs) +{ + if (DeltaTimeMs <= 0 || PlaybackState.Mode != EHyperTwistViewerPlaybackMode::Playing || MoveList.Num() == 0) + { + return false; + } + + const FHyperTwistViewerPlaybackState PreviousPlaybackState = PlaybackState; + const FHyperTwistViewerPositionSnapshot PreviousPositionSnapshot = PositionSnapshot; + const FHyperTwistViewerDirectionState PreviousDirectionState = DirectionState; + + PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::Forward; + const int32 PreviousTimeMs = PlaybackState.CurrentTimeMs; + PlaybackState.CurrentTimeMs = FMath::Clamp( + PlaybackState.CurrentTimeMs + DeltaTimeMs, + 0, + PlaybackState.TotalTimeMs + ); + + if (PlaybackState.TotalTimeMs > 0 && PlaybackState.CurrentTimeMs >= PlaybackState.TotalTimeMs) + { + PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Paused; + } + + RebuildAndBroadcast(PreviousPlaybackState, PreviousPositionSnapshot, PreviousDirectionState); + return PlaybackState.CurrentTimeMs != PreviousTimeMs; } // --------------------------------------------------------------------------- @@ -349,10 +723,42 @@ FHyperTwistViewerMoveEntry UHyperTwistPuzzleViewerComponent::GetCurrentMoveEntry // Private helpers // --------------------------------------------------------------------------- -void UHyperTwistPuzzleViewerComponent::RebuildAndBroadcast() +void UHyperTwistPuzzleViewerComponent::RebuildTimelineCache() +{ + MoveBoundaryTimesMs = HyperTwistPuzzleViewerComponentInternal::BuildMoveBoundaryTimesMs(MoveList); +} + +int32 UHyperTwistPuzzleViewerComponent::ResolveSeekTargetTimeMs(const int32 TargetIndex) const +{ + if (MoveBoundaryTimesMs.Num() == 0) + { + return 0; + } + + if (TargetIndex <= 0) + { + return 0; + } + + if (TargetIndex >= MoveBoundaryTimesMs.Num() - 1) + { + return PlaybackState.TotalTimeMs > 0 ? PlaybackState.TotalTimeMs : MoveBoundaryTimesMs.Last(); + } + + return MoveBoundaryTimesMs[TargetIndex]; +} + +void UHyperTwistPuzzleViewerComponent::RebuildAndBroadcast( + const FHyperTwistViewerPlaybackState& PreviousPlaybackState, + const FHyperTwistViewerPositionSnapshot& PreviousPositionSnapshot, + const FHyperTwistViewerDirectionState& PreviousDirectionState, + const FHyperTwistViewerJumpEvent* JumpEvent +) { const bool bHasMoves = MoveList.Num() > 0; + RebuildTimelineCache(); + PlaybackState.TotalMoveCount = MoveList.Num(); PlaybackState.bSceneContextLoaded = SceneContext.IsStructurallyValid(); PlaybackState.bReplayLoaded = bReplayPacketLoaded; @@ -361,15 +767,21 @@ void UHyperTwistPuzzleViewerComponent::RebuildAndBroadcast() ? ReplayPacket.PuzzleDefinition.PuzzleId : (SceneContext.IsStructurallyValid() ? SceneContext.PuzzleState.Definition.PuzzleId : FString()); PlaybackState.ReplayResult = bReplayPacketLoaded ? ReplaySummary.Result : FString(); - PlaybackState.TotalTimeMs = bReplayPacketLoaded ? FMath::Max(0, ReplaySummary.TotalTimeMs) : 0; + PlaybackState.TotalTimeMs = HyperTwistPuzzleViewerComponentInternal::ResolvePlaybackTerminalTimeMs( + MoveBoundaryTimesMs, + ReplaySummary, + bReplayPacketLoaded + ); PlaybackState.InspectionTimeMs = bReplayPacketLoaded ? FMath::Max(0, ReplaySummary.InspectionTimeMs) : 0; + PlaybackState.CurrentTimeMs = FMath::Clamp(PlaybackState.CurrentTimeMs, 0, PlaybackState.TotalTimeMs); if (!bHasMoves) { PlaybackState.CurrentMoveIndex = INDEX_NONE; - PlaybackState.bAtStart = true; - PlaybackState.bAtEnd = true; - PlaybackState.CurrentTimeMs = 0; + PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::None; + PlaybackState.bAtStart = true; + PlaybackState.bAtEnd = true; + PlaybackState.CurrentTimeMs = 0; if (PlaybackState.bReplayLoaded) { @@ -392,13 +804,24 @@ void UHyperTwistPuzzleViewerComponent::RebuildAndBroadcast() } else { - const int32 LastIndex = MoveList.Num() - 1; - PlaybackState.CurrentMoveIndex = FMath::Clamp(PlaybackState.CurrentMoveIndex, 0, LastIndex); - PlaybackState.bAtStart = (PlaybackState.CurrentMoveIndex <= 0); - PlaybackState.bAtEnd = (PlaybackState.CurrentMoveIndex >= LastIndex); - PlaybackState.CurrentTimeMs = bReplayPacketLoaded - ? HyperTwistPuzzleViewerComponentInternal::DeriveCurrentTimeMs(MoveList, PlaybackState, ReplaySummary) - : 0; + DirectionState = HyperTwistPuzzleViewerComponentInternal::BuildDirectionState( + PlaybackState.Mode, + PlaybackState.Direction + ); + PositionSnapshot = HyperTwistPuzzleViewerComponentInternal::BuildPositionSnapshot( + MoveList, + MoveBoundaryTimesMs, + PlaybackState.CurrentTimeMs, + PlaybackState.TotalTimeMs, + PlaybackState.Direction + ); + PlaybackState.CurrentMoveIndex = HyperTwistPuzzleViewerComponentInternal::FindDisplayMoveIndexForTime( + MoveBoundaryTimesMs, + PlaybackState.CurrentTimeMs, + PlaybackState.TotalTimeMs + ); + PlaybackState.bAtStart = PositionSnapshot.bAtStart; + PlaybackState.bAtEnd = PositionSnapshot.bAtEnd; PlaybackState.StatusLabel = UHyperTwistViewerLibrary::GetControlBarReadout(PlaybackState, GetCurrentMoveEntry()); if (PlaybackState.bReplayLoaded && !PlaybackState.ReplayResult.IsEmpty()) { @@ -406,5 +829,38 @@ void UHyperTwistPuzzleViewerComponent::RebuildAndBroadcast() } } + if (!bHasMoves) + { + DirectionState = HyperTwistPuzzleViewerComponentInternal::BuildDirectionState( + PlaybackState.Mode, + PlaybackState.Direction + ); + PositionSnapshot = HyperTwistPuzzleViewerComponentInternal::BuildPositionSnapshot( + MoveList, + MoveBoundaryTimesMs, + PlaybackState.CurrentTimeMs, + PlaybackState.TotalTimeMs, + PlaybackState.Direction + ); + } + OnPlaybackStateChanged.Broadcast(PlaybackState); + + if (!HyperTwistPuzzleViewerComponentInternal::ArePositionSnapshotsEquivalent(PreviousPositionSnapshot, PositionSnapshot)) + { + OnPositionSnapshotUpdated.Broadcast(PositionSnapshot); + OnPositionSnapshotUpdatedNative.Broadcast(PositionSnapshot); + } + + if (!HyperTwistPuzzleViewerComponentInternal::AreDirectionStatesEquivalent(PreviousDirectionState, DirectionState)) + { + OnDirectionStateChanged.Broadcast(DirectionState); + OnDirectionStateChangedNative.Broadcast(DirectionState); + } + + if (JumpEvent != nullptr && JumpEvent->IsStructurallyValid()) + { + OnPlaybackJumped.Broadcast(*JumpEvent); + OnPlaybackJumpedNative.Broadcast(*JumpEvent); + } } diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h index dafea01..6d96714 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h @@ -22,14 +22,44 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam( const FHyperTwistViewerPlaybackState&, NewState ); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam( + FOnViewerPositionSnapshotUpdated, + const FHyperTwistViewerPositionSnapshot&, NewSnapshot +); + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam( + FOnViewerDirectionStateChanged, + const FHyperTwistViewerDirectionState&, NewDirectionState +); + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam( + FOnViewerPlaybackJumped, + const FHyperTwistViewerJumpEvent&, JumpEvent +); + +DECLARE_MULTICAST_DELEGATE_OneParam( + FOnViewerPositionSnapshotUpdatedNative, + const FHyperTwistViewerPositionSnapshot& +); + +DECLARE_MULTICAST_DELEGATE_OneParam( + FOnViewerDirectionStateChangedNative, + const FHyperTwistViewerDirectionState& +); + +DECLARE_MULTICAST_DELEGATE_OneParam( + FOnViewerPlaybackJumpedNative, + const FHyperTwistViewerJumpEvent& +); + // --------------------------------------------------------------------------- // UHyperTwistPuzzleViewerComponent // // Holds a loaded scene context and a flat move list, and manages a pure // state-machine playback cursor over that list. // -// Bound 1 scope: state management and delegate broadcasting only. -// Tick-based animation is Bound 2. Adapter lifecycle is Bound 3. +// Bound 2 scope: observer channels and deterministic cursor/timeline transport. +// Adapter lifecycle is Bound 3. // Viewport rendering is Bound 4. // --------------------------------------------------------------------------- @@ -64,10 +94,16 @@ public: // ------------------------------------------------------------------ // Apply a control request. Advances, retreats, or repositions the playhead. - // Bound 1: pure state-machine. No animation is driven here. + // Bound 2: state-machine plus direction/jump observer updates. UFUNCTION(BlueprintCallable, Category = "HyperTwist|Viewer") void ApplyControlRequest(const FHyperTwistViewerControlRequest& Request); + // Advance the playing cursor by a deterministic timeline delta. + // Used by transport automation and host-driven playback without widening + // into renderer ownership. + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Viewer") + bool AdvancePlaybackByMs(int32 DeltaTimeMs); + // ------------------------------------------------------------------ // Readout accessors // ------------------------------------------------------------------ @@ -90,6 +126,12 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer") FHyperTwistDerivedReplaySummary GetReplaySummary() const { return ReplaySummary; } + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer") + FHyperTwistViewerPositionSnapshot GetPositionSnapshot() const { return PositionSnapshot; } + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer") + FHyperTwistViewerDirectionState GetDirectionState() const { return DirectionState; } + // ------------------------------------------------------------------ // Delegates // ------------------------------------------------------------------ @@ -97,6 +139,19 @@ public: UPROPERTY(BlueprintAssignable, Category = "HyperTwist|Viewer") FOnViewerPlaybackStateChanged OnPlaybackStateChanged; + UPROPERTY(BlueprintAssignable, Category = "HyperTwist|Viewer") + FOnViewerPositionSnapshotUpdated OnPositionSnapshotUpdated; + + UPROPERTY(BlueprintAssignable, Category = "HyperTwist|Viewer") + FOnViewerDirectionStateChanged OnDirectionStateChanged; + + UPROPERTY(BlueprintAssignable, Category = "HyperTwist|Viewer") + FOnViewerPlaybackJumped OnPlaybackJumped; + + FOnViewerPositionSnapshotUpdatedNative OnPositionSnapshotUpdatedNative; + FOnViewerDirectionStateChangedNative OnDirectionStateChangedNative; + FOnViewerPlaybackJumpedNative OnPlaybackJumpedNative; + protected: virtual void BeginPlay() override; @@ -112,6 +167,12 @@ private: UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer") FHyperTwistViewerPlaybackState PlaybackState; + UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer") + FHyperTwistViewerPositionSnapshot PositionSnapshot; + + UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer") + FHyperTwistViewerDirectionState DirectionState; + UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer") FHyperTwistReplayPacket ReplayPacket; @@ -121,6 +182,18 @@ private: UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer") bool bReplayPacketLoaded = false; - // Rebuild the playback state derived fields and broadcast the delegate. - void RebuildAndBroadcast(); + UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer") + TArray MoveBoundaryTimesMs; + + void RebuildTimelineCache(); + + int32 ResolveSeekTargetTimeMs(int32 TargetIndex) const; + + // Rebuild the playback state derived fields and broadcast split observers. + void RebuildAndBroadcast( + const FHyperTwistViewerPlaybackState& PreviousPlaybackState, + const FHyperTwistViewerPositionSnapshot& PreviousPositionSnapshot, + const FHyperTwistViewerDirectionState& PreviousDirectionState, + const FHyperTwistViewerJumpEvent* JumpEvent = nullptr + ); }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerTypes.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerTypes.h index fe2b850..1e2a047 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerTypes.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistViewerTypes.h @@ -24,6 +24,24 @@ enum class EHyperTwistViewerPlaybackMode : uint8 Stepping UMETA(DisplayName = "Stepping") }; +UENUM(BlueprintType) +enum class EHyperTwistViewerPlaybackDirection : uint8 +{ + None UMETA(DisplayName = "None"), + Forward UMETA(DisplayName = "Forward"), + Backward UMETA(DisplayName = "Backward") +}; + +UENUM(BlueprintType) +enum class EHyperTwistViewerJumpKind : uint8 +{ + Seek UMETA(DisplayName = "Seek"), + StepForward UMETA(DisplayName = "Step Forward"), + StepBack UMETA(DisplayName = "Step Back"), + Stop UMETA(DisplayName = "Stop"), + PlayWrap UMETA(DisplayName = "Play Wrap") +}; + // --------------------------------------------------------------------------- // Control action — what the caller wants the viewer to do // --------------------------------------------------------------------------- @@ -82,6 +100,9 @@ struct UNREALHYPERTWIST_API FHyperTwistViewerPlaybackState UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") EHyperTwistViewerPlaybackMode Mode = EHyperTwistViewerPlaybackMode::Stopped; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerPlaybackDirection Direction = EHyperTwistViewerPlaybackDirection::None; + // Zero-based index of the move currently at the playhead. // INDEX_NONE (-1) when the move list is empty. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") @@ -184,6 +205,143 @@ struct UNREALHYPERTWIST_API FHyperTwistViewerControlRequest } }; +USTRUCT(BlueprintType) +struct UNREALHYPERTWIST_API FHyperTwistViewerInFlightMoveProgress +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 MoveIndex = INDEX_NONE; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + FHyperTwistViewerMoveEntry MoveEntry; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerPlaybackDirection Direction = EHyperTwistViewerPlaybackDirection::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + float Fraction = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 StartedAtMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 EndsAtMs = 0; + + bool IsStructurallyValid() const + { + return MoveIndex >= 0 + && MoveEntry.IsStructurallyValid() + && FMath::IsFinite(Fraction) + && Fraction >= 0.0f + && Fraction <= 1.0f + && StartedAtMs >= 0 + && EndsAtMs >= StartedAtMs; + } +}; + +USTRUCT(BlueprintType) +struct UNREALHYPERTWIST_API FHyperTwistViewerPositionSnapshot +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 CurrentTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 BaseCompletedMoveIndex = INDEX_NONE; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 BaseBoundaryTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + TArray InFlightMoves; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bAtBoundary = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bAtStart = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bAtEnd = false; + + bool IsStructurallyValid() const + { + if (CurrentTimeMs < 0 || BaseBoundaryTimeMs < 0) + { + return false; + } + + if (BaseCompletedMoveIndex < INDEX_NONE) + { + return false; + } + + for (const FHyperTwistViewerInFlightMoveProgress& Progress : InFlightMoves) + { + if (!Progress.IsStructurallyValid()) + { + return false; + } + } + + return true; + } +}; + +USTRUCT(BlueprintType) +struct UNREALHYPERTWIST_API FHyperTwistViewerDirectionState +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerPlaybackMode Mode = EHyperTwistViewerPlaybackMode::Stopped; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerPlaybackDirection Direction = EHyperTwistViewerPlaybackDirection::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bIsContinuousPlayback = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bIsPaused = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + bool bIsStepping = false; + + bool IsStructurallyValid() const + { + return true; + } +}; + +USTRUCT(BlueprintType) +struct UNREALHYPERTWIST_API FHyperTwistViewerJumpEvent +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + EHyperTwistViewerJumpKind JumpKind = EHyperTwistViewerJumpKind::Seek; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 PreviousTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 NewTimeMs = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 PreviousMoveIndex = INDEX_NONE; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer") + int32 NewMoveIndex = INDEX_NONE; + + bool IsStructurallyValid() const + { + return PreviousTimeMs >= 0 && NewTimeMs >= 0; + } +}; + // --------------------------------------------------------------------------- // Runtime surface - minimal compact replay-player bundle exposed through the // training runtime library without moving shell ownership out of simulation. diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistTwistyBound2TimelineTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistTwistyBound2TimelineTest.cpp new file mode 100644 index 0000000..58f1a6a --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistTwistyBound2TimelineTest.cpp @@ -0,0 +1,273 @@ +// Copyright HyperTwist, Inc. All Rights Reserved. + +#include "Misc/AutomationTest.h" + +#include "HyperTwistBootstrap/HyperTwistContractLibrary.h" +#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h" +#include "HyperTwistSimulation/HyperTwistViewerLibrary.h" +#include "JsonObjectConverter.h" + +#if WITH_AUTOMATION_TESTS + +namespace HyperTwistTwistyBound2TimelineTestInternal +{ + template + FString SerializeStructToJson(const TStruct& Value) + { + FString Json; + FJsonObjectConverter::UStructToJsonObjectString(TStruct::StaticStruct(), &Value, Json, 0, 0); + return Json; + } + + FHyperTwistReplayEvent MakeReplayEvent( + const FString& EventId, + const int32 Sequence, + const int32 TimeMs, + const EHyperTwistReplayEventType EventType, + const FString& PayloadJson + ) + { + FHyperTwistReplayEvent Event; + Event.EventId = EventId; + Event.Sequence = Sequence; + Event.TimeMs = TimeMs; + Event.EventType = EventType; + Event.PayloadJson = PayloadJson; + return Event; + } + + FHyperTwistReplayPacket MakeReplayPacket() + { + const FHyperTwistPuzzleState SampleState = UHyperTwistContractLibrary::MakeSampleHyperPuzzleState(); + + FHyperTwistStateSnapshot Snapshot; + Snapshot.SnapshotId = TEXT("snapshot-bound2"); + Snapshot.State = SampleState; + Snapshot.DerivedHash = TEXT("snapshot-bound2-hash"); + + FHyperTwistReplayMovePayload FirstMovePayload; + FirstMovePayload.Notation = TEXT("R"); + FirstMovePayload.TransformationRef = TEXT("classic/r"); + FirstMovePayload.Source = TEXT("unit-test"); + + FHyperTwistReplayMovePayload SecondMovePayload; + SecondMovePayload.Notation = TEXT("U"); + SecondMovePayload.TransformationRef = TEXT("classic/u"); + SecondMovePayload.Source = TEXT("unit-test"); + + FHyperTwistReplayMovePayload ThirdMovePayload; + ThirdMovePayload.Notation = TEXT("F'"); + ThirdMovePayload.TransformationRef = TEXT("classic/f-prime"); + ThirdMovePayload.Source = TEXT("unit-test"); + + FHyperTwistReplayPacket ReplayPacket; + ReplayPacket.ReplayId = TEXT("replay-bound2-001"); + ReplayPacket.SessionId = TEXT("session-bound2-001"); + ReplayPacket.PuzzleDefinition = SampleState.Definition; + ReplayPacket.CaptureMode = EHyperTwistCaptureMode::Imported; + ReplayPacket.Events = { + MakeReplayEvent( + TEXT("evt-001"), + 1, + 0, + EHyperTwistReplayEventType::StateSnapshot, + SerializeStructToJson(Snapshot) + ), + MakeReplayEvent( + TEXT("evt-002"), + 2, + 0, + EHyperTwistReplayEventType::TimerStart, + TEXT("") + ), + MakeReplayEvent( + TEXT("evt-003"), + 3, + 200, + EHyperTwistReplayEventType::Move, + SerializeStructToJson(FirstMovePayload) + ), + MakeReplayEvent( + TEXT("evt-004"), + 4, + 400, + EHyperTwistReplayEventType::Move, + SerializeStructToJson(SecondMovePayload) + ), + MakeReplayEvent( + TEXT("evt-005"), + 5, + 700, + EHyperTwistReplayEventType::Move, + SerializeStructToJson(ThirdMovePayload) + ), + MakeReplayEvent( + TEXT("evt-006"), + 6, + 1000, + EHyperTwistReplayEventType::SolveEnd, + TEXT("") + ) + }; + + return ReplayPacket; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistTwistyBound2ObserverModelTest, + "HyperTwist.CleanRoom.TwistyJs.Bound2.ObserverModel", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistTwistyBound2ObserverModelTest::RunTest(const FString& Parameters) +{ + UHyperTwistPuzzleViewerComponent* Viewer = NewObject(); + TestNotNull(TEXT("Viewer component instance must be created."), Viewer); + TestTrue( + TEXT("Replay packet load must succeed for the Bound 2 observer test."), + Viewer->LoadReplayPacket(HyperTwistTwistyBound2TimelineTestInternal::MakeReplayPacket()) + ); + + TArray PositionEvents; + TArray DirectionEvents; + TArray JumpEvents; + + Viewer->OnPositionSnapshotUpdatedNative.AddLambda( + [&PositionEvents](const FHyperTwistViewerPositionSnapshot& Snapshot) + { + PositionEvents.Add(Snapshot); + } + ); + Viewer->OnDirectionStateChangedNative.AddLambda( + [&DirectionEvents](const FHyperTwistViewerDirectionState& State) + { + DirectionEvents.Add(State); + } + ); + Viewer->OnPlaybackJumpedNative.AddLambda( + [&JumpEvents](const FHyperTwistViewerJumpEvent& JumpEvent) + { + JumpEvents.Add(JumpEvent); + } + ); + + Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakePlayRequest()); + TestEqual(TEXT("Play should emit one direction-state event."), DirectionEvents.Num(), 1); + TestEqual( + TEXT("Play should switch the direction-state channel to forward playback."), + DirectionEvents.Last().Direction, + EHyperTwistViewerPlaybackDirection::Forward + ); + TestTrue(TEXT("Play should mark continuous playback active."), DirectionEvents.Last().bIsContinuousPlayback); + TestEqual(TEXT("Play should not emit a jump event by itself."), JumpEvents.Num(), 0); + + TestTrue(TEXT("Advance should move the playing cursor."), Viewer->AdvancePlaybackByMs(100)); + TestEqual(TEXT("Advancing the playing cursor should emit one position update."), PositionEvents.Num(), 1); + TestEqual(TEXT("The first position update should advance to 100ms."), PositionEvents.Last().CurrentTimeMs, 100); + TestEqual(TEXT("The first position update should keep the base boundary at the start."), PositionEvents.Last().BaseBoundaryTimeMs, 0); + TestEqual(TEXT("The first position update should expose one in-flight move."), PositionEvents.Last().InFlightMoves.Num(), 1); + TestEqual(TEXT("The in-flight move should be the first move."), PositionEvents.Last().InFlightMoves[0].MoveIndex, 0); + TestTrue( + TEXT("The first in-flight move should be halfway complete at 100ms of a 200ms segment."), + FMath::IsNearlyEqual(PositionEvents.Last().InFlightMoves[0].Fraction, 0.5f) + ); + TestEqual(TEXT("Continuous position updates should stay off the jump channel."), JumpEvents.Num(), 0); + + Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakePauseRequest()); + TestEqual(TEXT("Pause should emit a second direction-state event."), DirectionEvents.Num(), 2); + TestTrue(TEXT("Pause should mark the direction-state channel paused."), DirectionEvents.Last().bIsPaused); + + TestFalse(TEXT("Advancing while paused should not move the cursor."), Viewer->AdvancePlaybackByMs(50)); + TestEqual(TEXT("Advancing while paused should not emit another position update."), PositionEvents.Num(), 1); + + Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakeStepBackRequest()); + TestEqual(TEXT("Step back should emit a third direction-state event."), DirectionEvents.Num(), 3); + TestEqual( + TEXT("Step back should switch the direction-state channel to backward stepping."), + DirectionEvents.Last().Direction, + EHyperTwistViewerPlaybackDirection::Backward + ); + TestTrue(TEXT("Step back should mark the direction-state channel stepping."), DirectionEvents.Last().bIsStepping); + TestEqual(TEXT("Step back should emit one jump event."), JumpEvents.Num(), 1); + TestEqual(TEXT("The first jump event should record step-back semantics."), JumpEvents.Last().JumpKind, EHyperTwistViewerJumpKind::StepBack); + TestEqual(TEXT("Step back should jump from 100ms to 0ms."), JumpEvents.Last().PreviousTimeMs, 100); + TestEqual(TEXT("Step back should land at the start boundary."), JumpEvents.Last().NewTimeMs, 0); + TestEqual(TEXT("Step back should also emit a second position update."), PositionEvents.Num(), 2); + TestTrue(TEXT("The step-back position update should be a boundary snapshot."), PositionEvents.Last().bAtBoundary); + TestEqual(TEXT("The step-back position update should clear in-flight progress."), PositionEvents.Last().InFlightMoves.Num(), 0); + + Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakeSeekRequest(2)); + TestEqual(TEXT("Seek should emit a second jump event."), JumpEvents.Num(), 2); + TestEqual(TEXT("The second jump event should record seek semantics."), JumpEvents.Last().JumpKind, EHyperTwistViewerJumpKind::Seek); + TestEqual(TEXT("Seeking to the last move index should jump to the terminal replay time."), JumpEvents.Last().NewTimeMs, 1000); + TestEqual(TEXT("Seek should emit a fourth direction-state event."), DirectionEvents.Num(), 4); + TestEqual(TEXT("Seek should restore forward direction when seeking later."), DirectionEvents.Last().Direction, EHyperTwistViewerPlaybackDirection::Forward); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistTwistyBound2CursorTimelineTest, + "HyperTwist.CleanRoom.TwistyJs.Bound2.CursorTimeline", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistTwistyBound2CursorTimelineTest::RunTest(const FString& Parameters) +{ + UHyperTwistPuzzleViewerComponent* Viewer = NewObject(); + TestNotNull(TEXT("Viewer component instance must be created."), Viewer); + TestTrue( + TEXT("Replay packet load must succeed for the Bound 2 cursor test."), + Viewer->LoadReplayPacket(HyperTwistTwistyBound2TimelineTestInternal::MakeReplayPacket()) + ); + + Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakePlayRequest()); + TestTrue(TEXT("Cursor advance should move the playhead during continuous playback."), Viewer->AdvancePlaybackByMs(100)); + + FHyperTwistViewerPositionSnapshot Snapshot = Viewer->GetPositionSnapshot(); + TestEqual(TEXT("A 100ms cursor advance should expose the correct current time."), Snapshot.CurrentTimeMs, 100); + TestEqual(TEXT("The base boundary should stay at the start before the first move completes."), Snapshot.BaseCompletedMoveIndex, INDEX_NONE); + TestEqual(TEXT("The first partial segment should expose one in-flight move."), Snapshot.InFlightMoves.Num(), 1); + TestEqual(TEXT("The in-flight move should be the first move."), Snapshot.InFlightMoves[0].MoveIndex, 0); + TestTrue(TEXT("The first partial segment should report a 0.5 fraction."), FMath::IsNearlyEqual(Snapshot.InFlightMoves[0].Fraction, 0.5f)); + + TestTrue(TEXT("A second cursor advance should continue through the timeline."), Viewer->AdvancePlaybackByMs(150)); + Snapshot = Viewer->GetPositionSnapshot(); + TestEqual(TEXT("The cursor should now be at 250ms."), Snapshot.CurrentTimeMs, 250); + TestEqual(TEXT("The base completed move should now be move 0."), Snapshot.BaseCompletedMoveIndex, 0); + TestEqual(TEXT("The cursor should now interpolate the second move."), Snapshot.InFlightMoves[0].MoveIndex, 1); + TestTrue(TEXT("The second move should be 25% complete at 250ms."), FMath::IsNearlyEqual(Snapshot.InFlightMoves[0].Fraction, 0.25f)); + + Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakePauseRequest()); + const int32 PausedTimeMs = Viewer->GetPlaybackState().CurrentTimeMs; + TestFalse(TEXT("The cursor must not advance while paused."), Viewer->AdvancePlaybackByMs(200)); + TestEqual(TEXT("Pause must preserve the current timeline position."), Viewer->GetPlaybackState().CurrentTimeMs, PausedTimeMs); + + Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakeStepForwardRequest()); + Snapshot = Viewer->GetPositionSnapshot(); + TestEqual(TEXT("Step forward should land on the next move boundary, not a partial continuous time."), Snapshot.CurrentTimeMs, 400); + TestTrue(TEXT("A move-boundary step should land on a boundary snapshot."), Snapshot.bAtBoundary); + TestEqual(TEXT("Step forward should expose no in-flight move at the boundary."), Snapshot.InFlightMoves.Num(), 0); + TestEqual(TEXT("The base completed move should now be move 1."), Snapshot.BaseCompletedMoveIndex, 1); + + Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakeStepBackRequest()); + Snapshot = Viewer->GetPositionSnapshot(); + TestEqual(TEXT("Step back should return to the previous move boundary."), Snapshot.CurrentTimeMs, 200); + TestEqual(TEXT("Step back should restore move 0 as the completed boundary anchor."), Snapshot.BaseCompletedMoveIndex, 0); + + Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakePlayRequest()); + TestTrue(TEXT("Resume should continue from the paused move boundary."), Viewer->AdvancePlaybackByMs(100)); + TestEqual(TEXT("Resume should continue from the paused boundary instead of restarting."), Viewer->GetPlaybackState().CurrentTimeMs, 300); + + Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakeSeekRequest(2)); + Snapshot = Viewer->GetPositionSnapshot(); + TestEqual(TEXT("Seeking to the last move index should land on the terminal replay time."), Snapshot.CurrentTimeMs, 1000); + TestTrue(TEXT("Seeking to the last move index should land at the end state."), Snapshot.bAtEnd); + TestEqual(TEXT("The final completed boundary anchor should remain the last move."), Snapshot.BaseCompletedMoveIndex, 2); + TestEqual(TEXT("The scrubber should remain coherent at the terminal end state."), UHyperTwistViewerLibrary::GetScrubberNormalizedPosition(Viewer->GetPlaybackState()), 1.0f); + + return true; +} + +#endif // WITH_AUTOMATION_TESTS