Add Bound 2 viewer timeline transport

This commit is contained in:
axiomlogicnexus 2026-05-17 18:56:55 +02:00
parent eaeece995a
commit bcd1f9ff4e
4 changed files with 1004 additions and 44 deletions

View file

@ -102,6 +102,48 @@ namespace HyperTwistPuzzleViewerComponentInternal
return Result;
}
TArray<int32> BuildMoveBoundaryTimesMs(const TArray<FHyperTwistViewerMoveEntry>& MoveList)
{
TArray<int32> 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<int32>& 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<int32>& 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<int32>& 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<int32>& MoveBoundaryTimesMs, const int32 CurrentTimeMs)
{
for (const int32 MoveBoundaryTimeMs : MoveBoundaryTimesMs)
{
if (MoveBoundaryTimeMs > CurrentTimeMs)
{
return MoveBoundaryTimeMs;
}
}
return CurrentTimeMs;
}
int32 ResolvePreviousMoveBoundaryTimeMs(const TArray<int32>& MoveBoundaryTimesMs, const int32 CurrentTimeMs)
{
int32 PreviousBoundaryTimeMs = 0;
for (const int32 MoveBoundaryTimeMs : MoveBoundaryTimesMs)
{
if (MoveBoundaryTimeMs >= CurrentTimeMs)
{
break;
}
PreviousBoundaryTimeMs = MoveBoundaryTimeMs;
}
return PreviousBoundaryTimeMs;
}
FHyperTwistViewerPositionSnapshot BuildPositionSnapshot(
const TArray<FHyperTwistViewerMoveEntry>& MoveList,
const TArray<int32>& 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<float>(Snapshot.CurrentTimeMs - SegmentStartTimeMs) / static_cast<float>(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<FHyperTwistViewerMoveEntry>& 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);
}
}

View file

@ -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<int32> 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
);
};

View file

@ -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<FHyperTwistViewerInFlightMoveProgress> 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.

View file

@ -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 <typename TStruct>
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<UHyperTwistPuzzleViewerComponent>();
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<FHyperTwistViewerPositionSnapshot> PositionEvents;
TArray<FHyperTwistViewerDirectionState> DirectionEvents;
TArray<FHyperTwistViewerJumpEvent> 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<UHyperTwistPuzzleViewerComponent>();
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