Close twisty.js Bound 1 runtime surface
This commit is contained in:
parent
2d5e224a69
commit
eaeece995a
8 changed files with 1284 additions and 0 deletions
|
|
@ -0,0 +1,410 @@
|
|||
// Clean-room implementation for HyperTwist puzzle viewer subsystem — Bound 1
|
||||
// Lane: cubing/twisty.js (GPL-3.0-or-later — restrictive clean-room lane)
|
||||
// Implemented from governance docs and abstract idea handoff only.
|
||||
// No source code inspection. Clean-room workflow only.
|
||||
// Date: 2026-05-17
|
||||
|
||||
#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h"
|
||||
|
||||
#include "HyperTwistReplay/HyperTwistReplayLibrary.h"
|
||||
#include "HyperTwistSimulation/HyperTwistViewerLibrary.h"
|
||||
#include "JsonObjectConverter.h"
|
||||
|
||||
namespace HyperTwistPuzzleViewerComponentInternal
|
||||
{
|
||||
template <typename TStruct>
|
||||
bool DeserializePayload(const FString& Json, TStruct& OutValue)
|
||||
{
|
||||
if (Json.IsEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return FJsonObjectConverter::JsonObjectStringToUStruct(Json, &OutValue, 0, 0);
|
||||
}
|
||||
|
||||
FHyperTwistTransformation BuildReplayMoveTransform(
|
||||
const FHyperTwistReplayPacket& ReplayPacket,
|
||||
const FHyperTwistReplayEvent& Event,
|
||||
const FHyperTwistReplayMovePayload* MovePayload,
|
||||
const bool bPayloadParsed
|
||||
)
|
||||
{
|
||||
FHyperTwistTransformation Transform;
|
||||
Transform.Definition = ReplayPacket.PuzzleDefinition;
|
||||
Transform.TransformKind = EHyperTwistTransformKind::SingleMove;
|
||||
Transform.TransformEncoding.EncodingProfile = TEXT("ht-replay-move-ref/v1");
|
||||
Transform.TransformEncoding.PayloadJson = Event.PayloadJson;
|
||||
|
||||
FString ResolvedNotation = Event.EventId;
|
||||
if (MovePayload != nullptr)
|
||||
{
|
||||
if (!MovePayload->Notation.IsEmpty())
|
||||
{
|
||||
ResolvedNotation = MovePayload->Notation;
|
||||
}
|
||||
else if (!MovePayload->TransformationRef.IsEmpty())
|
||||
{
|
||||
ResolvedNotation = MovePayload->TransformationRef;
|
||||
}
|
||||
}
|
||||
|
||||
if (ResolvedNotation.IsEmpty())
|
||||
{
|
||||
ResolvedNotation = FString::Printf(TEXT("move-%d"), Event.Sequence);
|
||||
}
|
||||
|
||||
Transform.Notation = ResolvedNotation;
|
||||
Transform.OriginalNotation = ResolvedNotation;
|
||||
|
||||
if (!bPayloadParsed)
|
||||
{
|
||||
Transform.NormalizationWarnings.Add(TEXT("replay-move-payload-unparsed"));
|
||||
}
|
||||
|
||||
return Transform;
|
||||
}
|
||||
|
||||
FHyperTwistViewerMoveEntry BuildReplayMoveEntry(
|
||||
const FHyperTwistReplayPacket& ReplayPacket,
|
||||
const FHyperTwistReplayEvent& Event
|
||||
)
|
||||
{
|
||||
FHyperTwistReplayMovePayload MovePayload;
|
||||
const bool bPayloadParsed = DeserializePayload(Event.PayloadJson, MovePayload);
|
||||
|
||||
FHyperTwistViewerMoveEntry Entry;
|
||||
Entry.Transform = BuildReplayMoveTransform(
|
||||
ReplayPacket,
|
||||
Event,
|
||||
bPayloadParsed ? &MovePayload : nullptr,
|
||||
bPayloadParsed
|
||||
);
|
||||
Entry.DisplayLabel = Entry.Transform.Notation;
|
||||
Entry.HintPositionSeconds = static_cast<float>(FMath::Max(0, Event.TimeMs)) / 1000.0f;
|
||||
return Entry;
|
||||
}
|
||||
|
||||
TArray<FHyperTwistViewerMoveEntry> BuildReplayMoveList(const FHyperTwistReplayPacket& ReplayPacket)
|
||||
{
|
||||
TArray<FHyperTwistViewerMoveEntry> Result;
|
||||
|
||||
for (const FHyperTwistReplayEvent& Event : ReplayPacket.Events)
|
||||
{
|
||||
if (Event.EventType != EHyperTwistReplayEventType::Move)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Result.Add(BuildReplayMoveEntry(ReplayPacket, Event));
|
||||
}
|
||||
|
||||
return Result;
|
||||
}
|
||||
|
||||
bool TryBuildSceneContextFromReplay(
|
||||
const FHyperTwistReplayPacket& ReplayPacket,
|
||||
FHyperTwistSimulationSceneContext& OutContext
|
||||
)
|
||||
{
|
||||
for (const FHyperTwistReplayEvent& Event : ReplayPacket.Events)
|
||||
{
|
||||
if (Event.EventType != EHyperTwistReplayEventType::StateSnapshot)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
FHyperTwistStateSnapshot Snapshot;
|
||||
if (!DeserializePayload(Event.PayloadJson, Snapshot) || !Snapshot.IsStructurallyValid())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
OutContext.SceneContextId = !ReplayPacket.ReplayId.IsEmpty()
|
||||
? FString::Printf(TEXT("replay/%s"), *ReplayPacket.ReplayId)
|
||||
: Snapshot.SnapshotId;
|
||||
OutContext.PuzzleState = Snapshot.State;
|
||||
OutContext.RenderStateProfile = ReplayPacket.PuzzleDefinition.PuzzleFamily == EHyperTwistPuzzleFamily::ClassicCube
|
||||
? TEXT("classic-replay")
|
||||
: TEXT("replay-default");
|
||||
return OutContext.IsStructurallyValid();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
int32 DeriveCurrentTimeMs(
|
||||
const TArray<FHyperTwistViewerMoveEntry>& MoveList,
|
||||
const FHyperTwistViewerPlaybackState& PlaybackState,
|
||||
const FHyperTwistDerivedReplaySummary& ReplaySummary
|
||||
)
|
||||
{
|
||||
if (MoveList.Num() == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (PlaybackState.bAtStart)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (PlaybackState.bAtEnd && ReplaySummary.TotalTimeMs > 0)
|
||||
{
|
||||
return ReplaySummary.TotalTimeMs;
|
||||
}
|
||||
|
||||
if (MoveList.IsValidIndex(PlaybackState.CurrentMoveIndex))
|
||||
{
|
||||
return FMath::RoundToInt(FMath::Max(0.0f, MoveList[PlaybackState.CurrentMoveIndex].HintPositionSeconds) * 1000.0f);
|
||||
}
|
||||
|
||||
return FMath::Max(0, ReplaySummary.TotalTimeMs);
|
||||
}
|
||||
}
|
||||
|
||||
UHyperTwistPuzzleViewerComponent::UHyperTwistPuzzleViewerComponent()
|
||||
{
|
||||
PrimaryComponentTick.bCanEverTick = false;
|
||||
|
||||
// Initialise playback state to a clean stopped-empty state.
|
||||
PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped;
|
||||
PlaybackState.CurrentMoveIndex = INDEX_NONE;
|
||||
PlaybackState.TotalMoveCount = 0;
|
||||
PlaybackState.CurrentTimeMs = 0;
|
||||
PlaybackState.TotalTimeMs = 0;
|
||||
PlaybackState.InspectionTimeMs = 0;
|
||||
PlaybackState.bAtStart = true;
|
||||
PlaybackState.bAtEnd = true;
|
||||
PlaybackState.bSceneContextLoaded = false;
|
||||
PlaybackState.bReplayLoaded = false;
|
||||
PlaybackState.StatusLabel = TEXT("No scene loaded");
|
||||
}
|
||||
|
||||
void UHyperTwistPuzzleViewerComponent::BeginPlay()
|
||||
{
|
||||
Super::BeginPlay();
|
||||
// Nothing to drive on BeginPlay in Bound 1 — tick and animation are Bound 2.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void UHyperTwistPuzzleViewerComponent::LoadSceneContext(const FHyperTwistSimulationSceneContext& InContext)
|
||||
{
|
||||
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();
|
||||
|
||||
RebuildAndBroadcast();
|
||||
}
|
||||
|
||||
void UHyperTwistPuzzleViewerComponent::LoadMoveList(const TArray<FHyperTwistViewerMoveEntry>& InMoveList)
|
||||
{
|
||||
MoveList = InMoveList;
|
||||
ReplayPacket = FHyperTwistReplayPacket();
|
||||
ReplaySummary = FHyperTwistDerivedReplaySummary();
|
||||
bReplayPacketLoaded = false;
|
||||
|
||||
PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped;
|
||||
PlaybackState.TotalMoveCount = MoveList.Num();
|
||||
PlaybackState.CurrentMoveIndex = MoveList.Num() > 0 ? 0 : INDEX_NONE;
|
||||
|
||||
RebuildAndBroadcast();
|
||||
}
|
||||
|
||||
bool UHyperTwistPuzzleViewerComponent::LoadReplayPacket(const FHyperTwistReplayPacket& InReplayPacket)
|
||||
{
|
||||
if (!InReplayPacket.IsStructurallyValid())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ReplayPacket = InReplayPacket;
|
||||
ReplaySummary = UHyperTwistReplayLibrary::DeriveReplaySummary(InReplayPacket);
|
||||
bReplayPacketLoaded = true;
|
||||
MoveList = HyperTwistPuzzleViewerComponentInternal::BuildReplayMoveList(InReplayPacket);
|
||||
|
||||
FHyperTwistSimulationSceneContext ReplaySceneContext;
|
||||
if (HyperTwistPuzzleViewerComponentInternal::TryBuildSceneContextFromReplay(InReplayPacket, ReplaySceneContext))
|
||||
{
|
||||
SceneContext = ReplaySceneContext;
|
||||
}
|
||||
else if (!SceneContext.IsStructurallyValid()
|
||||
|| SceneContext.PuzzleState.Definition.PuzzleId != InReplayPacket.PuzzleDefinition.PuzzleId)
|
||||
{
|
||||
SceneContext = FHyperTwistSimulationSceneContext();
|
||||
}
|
||||
|
||||
PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped;
|
||||
PlaybackState.TotalMoveCount = MoveList.Num();
|
||||
PlaybackState.CurrentMoveIndex = MoveList.Num() > 0 ? 0 : INDEX_NONE;
|
||||
|
||||
RebuildAndBroadcast();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control request state machine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void UHyperTwistPuzzleViewerComponent::ApplyControlRequest(const FHyperTwistViewerControlRequest& Request)
|
||||
{
|
||||
if (!Request.IsStructurallyValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const int32 LastIndex = MoveList.Num() - 1;
|
||||
const bool bHasMoves = MoveList.Num() > 0;
|
||||
|
||||
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)
|
||||
{
|
||||
PlaybackState.CurrentMoveIndex = 0;
|
||||
}
|
||||
PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Playing;
|
||||
}
|
||||
break;
|
||||
|
||||
case EHyperTwistViewerControlAction::Pause:
|
||||
if (PlaybackState.Mode == EHyperTwistViewerPlaybackMode::Playing)
|
||||
{
|
||||
PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Paused;
|
||||
}
|
||||
break;
|
||||
|
||||
case EHyperTwistViewerControlAction::Stop:
|
||||
PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stopped;
|
||||
PlaybackState.CurrentMoveIndex = bHasMoves ? 0 : INDEX_NONE;
|
||||
break;
|
||||
|
||||
case EHyperTwistViewerControlAction::StepForward:
|
||||
if (bHasMoves)
|
||||
{
|
||||
PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping;
|
||||
if (PlaybackState.CurrentMoveIndex < LastIndex)
|
||||
{
|
||||
PlaybackState.CurrentMoveIndex++;
|
||||
}
|
||||
// At end — remain at last index, mode stays Stepping (caller can check bAtEnd).
|
||||
}
|
||||
break;
|
||||
|
||||
case EHyperTwistViewerControlAction::StepBack:
|
||||
if (bHasMoves)
|
||||
{
|
||||
PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping;
|
||||
if (PlaybackState.CurrentMoveIndex > 0)
|
||||
{
|
||||
PlaybackState.CurrentMoveIndex--;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case EHyperTwistViewerControlAction::SeekToIndex:
|
||||
{
|
||||
if (bHasMoves)
|
||||
{
|
||||
// Clamp to valid range.
|
||||
const int32 ClampedIndex = FMath::Clamp(Request.SeekTargetIndex, 0, LastIndex);
|
||||
PlaybackState.CurrentMoveIndex = ClampedIndex;
|
||||
PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
RebuildAndBroadcast();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Readout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
FHyperTwistViewerMoveEntry UHyperTwistPuzzleViewerComponent::GetCurrentMoveEntry() const
|
||||
{
|
||||
if (MoveList.IsValidIndex(PlaybackState.CurrentMoveIndex))
|
||||
{
|
||||
return MoveList[PlaybackState.CurrentMoveIndex];
|
||||
}
|
||||
return FHyperTwistViewerMoveEntry{};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void UHyperTwistPuzzleViewerComponent::RebuildAndBroadcast()
|
||||
{
|
||||
const bool bHasMoves = MoveList.Num() > 0;
|
||||
|
||||
PlaybackState.TotalMoveCount = MoveList.Num();
|
||||
PlaybackState.bSceneContextLoaded = SceneContext.IsStructurallyValid();
|
||||
PlaybackState.bReplayLoaded = bReplayPacketLoaded;
|
||||
PlaybackState.ReplayId = bReplayPacketLoaded ? ReplayPacket.ReplayId : FString();
|
||||
PlaybackState.PuzzleId = bReplayPacketLoaded
|
||||
? 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.InspectionTimeMs = bReplayPacketLoaded ? FMath::Max(0, ReplaySummary.InspectionTimeMs) : 0;
|
||||
|
||||
if (!bHasMoves)
|
||||
{
|
||||
PlaybackState.CurrentMoveIndex = INDEX_NONE;
|
||||
PlaybackState.bAtStart = true;
|
||||
PlaybackState.bAtEnd = true;
|
||||
PlaybackState.CurrentTimeMs = 0;
|
||||
|
||||
if (PlaybackState.bReplayLoaded)
|
||||
{
|
||||
PlaybackState.StatusLabel = FString::Printf(
|
||||
TEXT("%s - 0 moves - %s"),
|
||||
*UHyperTwistViewerLibrary::GetPlaybackModeLabel(PlaybackState.Mode),
|
||||
*UHyperTwistViewerLibrary::GetTimelineLabel(PlaybackState)
|
||||
);
|
||||
if (!PlaybackState.ReplayResult.IsEmpty())
|
||||
{
|
||||
PlaybackState.StatusLabel += FString::Printf(TEXT(" - %s"), *PlaybackState.ReplayResult);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
PlaybackState.StatusLabel = PlaybackState.bSceneContextLoaded
|
||||
? TEXT("Scene loaded - no moves")
|
||||
: TEXT("No scene loaded");
|
||||
}
|
||||
}
|
||||
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;
|
||||
PlaybackState.StatusLabel = UHyperTwistViewerLibrary::GetControlBarReadout(PlaybackState, GetCurrentMoveEntry());
|
||||
if (PlaybackState.bReplayLoaded && !PlaybackState.ReplayResult.IsEmpty())
|
||||
{
|
||||
PlaybackState.StatusLabel += FString::Printf(TEXT(" - %s"), *PlaybackState.ReplayResult);
|
||||
}
|
||||
}
|
||||
|
||||
OnPlaybackStateChanged.Broadcast(PlaybackState);
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
// Clean-room implementation for HyperTwist puzzle viewer subsystem — Bound 1
|
||||
// Lane: cubing/twisty.js (GPL-3.0-or-later — restrictive clean-room lane)
|
||||
// Implemented from governance docs and abstract idea handoff only.
|
||||
// No source code inspection. Clean-room workflow only.
|
||||
// Date: 2026-05-17
|
||||
|
||||
#include "HyperTwistSimulation/HyperTwistViewerLibrary.h"
|
||||
|
||||
namespace HyperTwistViewerLibraryInternal
|
||||
{
|
||||
FString FormatTimelineSeconds(const int32 TimeMs)
|
||||
{
|
||||
return FString::Printf(TEXT("%.2fs"), static_cast<double>(FMath::Max(0, TimeMs)) / 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control request constructors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
FHyperTwistViewerControlRequest UHyperTwistViewerLibrary::MakePlayRequest()
|
||||
{
|
||||
FHyperTwistViewerControlRequest R;
|
||||
R.Action = EHyperTwistViewerControlAction::Play;
|
||||
return R;
|
||||
}
|
||||
|
||||
FHyperTwistViewerControlRequest UHyperTwistViewerLibrary::MakePauseRequest()
|
||||
{
|
||||
FHyperTwistViewerControlRequest R;
|
||||
R.Action = EHyperTwistViewerControlAction::Pause;
|
||||
return R;
|
||||
}
|
||||
|
||||
FHyperTwistViewerControlRequest UHyperTwistViewerLibrary::MakeStopRequest()
|
||||
{
|
||||
FHyperTwistViewerControlRequest R;
|
||||
R.Action = EHyperTwistViewerControlAction::Stop;
|
||||
return R;
|
||||
}
|
||||
|
||||
FHyperTwistViewerControlRequest UHyperTwistViewerLibrary::MakeStepForwardRequest()
|
||||
{
|
||||
FHyperTwistViewerControlRequest R;
|
||||
R.Action = EHyperTwistViewerControlAction::StepForward;
|
||||
return R;
|
||||
}
|
||||
|
||||
FHyperTwistViewerControlRequest UHyperTwistViewerLibrary::MakeStepBackRequest()
|
||||
{
|
||||
FHyperTwistViewerControlRequest R;
|
||||
R.Action = EHyperTwistViewerControlAction::StepBack;
|
||||
return R;
|
||||
}
|
||||
|
||||
FHyperTwistViewerControlRequest UHyperTwistViewerLibrary::MakeSeekRequest(int32 TargetIndex)
|
||||
{
|
||||
FHyperTwistViewerControlRequest R;
|
||||
R.Action = EHyperTwistViewerControlAction::SeekToIndex;
|
||||
R.SeekTargetIndex = FMath::Max(0, TargetIndex);
|
||||
return R;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Move entry construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
FHyperTwistViewerMoveEntry UHyperTwistViewerLibrary::MakeMoveEntry(
|
||||
const FHyperTwistTransformation& Transform,
|
||||
const FString& DisplayLabel,
|
||||
float HintPositionSeconds
|
||||
)
|
||||
{
|
||||
FHyperTwistViewerMoveEntry Entry;
|
||||
Entry.Transform = Transform;
|
||||
Entry.DisplayLabel = DisplayLabel;
|
||||
Entry.HintPositionSeconds = FMath::Max(0.0f, HintPositionSeconds);
|
||||
return Entry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scrubber accessors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
float UHyperTwistViewerLibrary::GetScrubberNormalizedPosition(const FHyperTwistViewerPlaybackState& State)
|
||||
{
|
||||
if (State.TotalTimeMs > 0)
|
||||
{
|
||||
if (State.bAtEnd)
|
||||
{
|
||||
return 1.0f;
|
||||
}
|
||||
|
||||
return FMath::Clamp(
|
||||
static_cast<float>(FMath::Clamp(State.CurrentTimeMs, 0, State.TotalTimeMs))
|
||||
/ static_cast<float>(State.TotalTimeMs),
|
||||
0.0f,
|
||||
1.0f
|
||||
);
|
||||
}
|
||||
|
||||
if (State.TotalMoveCount <= 1 || State.CurrentMoveIndex == INDEX_NONE)
|
||||
{
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const float MaxIndex = static_cast<float>(State.TotalMoveCount - 1);
|
||||
return FMath::Clamp(static_cast<float>(State.CurrentMoveIndex) / MaxIndex, 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
FString UHyperTwistViewerLibrary::GetMoveNotationLabel(const FHyperTwistViewerMoveEntry& Entry)
|
||||
{
|
||||
return Entry.DisplayLabel.IsEmpty() ? TEXT("-") : Entry.DisplayLabel;
|
||||
}
|
||||
|
||||
FString UHyperTwistViewerLibrary::GetPlaybackModeLabel(EHyperTwistViewerPlaybackMode Mode)
|
||||
{
|
||||
switch (Mode)
|
||||
{
|
||||
case EHyperTwistViewerPlaybackMode::Playing: return TEXT("Playing");
|
||||
case EHyperTwistViewerPlaybackMode::Paused: return TEXT("Paused");
|
||||
case EHyperTwistViewerPlaybackMode::Stepping: return TEXT("Stepping");
|
||||
default: return TEXT("Stopped");
|
||||
}
|
||||
}
|
||||
|
||||
FString UHyperTwistViewerLibrary::GetStepCounterLabel(const FHyperTwistViewerPlaybackState& State)
|
||||
{
|
||||
if (State.TotalMoveCount == 0 || State.CurrentMoveIndex == INDEX_NONE)
|
||||
{
|
||||
return TEXT("-- / --");
|
||||
}
|
||||
return FString::Printf(TEXT("%d / %d"), State.CurrentMoveIndex + 1, State.TotalMoveCount);
|
||||
}
|
||||
|
||||
FString UHyperTwistViewerLibrary::GetTimelineLabel(const FHyperTwistViewerPlaybackState& State)
|
||||
{
|
||||
if (!State.bReplayLoaded && State.TotalTimeMs <= 0)
|
||||
{
|
||||
return TEXT("-- / --");
|
||||
}
|
||||
|
||||
return FString::Printf(
|
||||
TEXT("%s / %s"),
|
||||
*HyperTwistViewerLibraryInternal::FormatTimelineSeconds(State.CurrentTimeMs),
|
||||
*HyperTwistViewerLibraryInternal::FormatTimelineSeconds(State.TotalTimeMs)
|
||||
);
|
||||
}
|
||||
|
||||
FString UHyperTwistViewerLibrary::GetControlBarReadout(
|
||||
const FHyperTwistViewerPlaybackState& State,
|
||||
const FHyperTwistViewerMoveEntry& CurrentEntry
|
||||
)
|
||||
{
|
||||
const FString ModeLabel = GetPlaybackModeLabel(State.Mode);
|
||||
const FString StepLabel = GetStepCounterLabel(State);
|
||||
const FString TimelineLabel = GetTimelineLabel(State);
|
||||
const FString NotationLabel = GetMoveNotationLabel(CurrentEntry);
|
||||
|
||||
return FString::Printf(TEXT("%s - %s - %s - %s"), *ModeLabel, *StepLabel, *TimelineLabel, *NotationLabel);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boundary queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool UHyperTwistViewerLibrary::IsPlaybackAtStart(const FHyperTwistViewerPlaybackState& State)
|
||||
{
|
||||
return State.bAtStart;
|
||||
}
|
||||
|
||||
bool UHyperTwistViewerLibrary::IsPlaybackAtEnd(const FHyperTwistViewerPlaybackState& State)
|
||||
{
|
||||
return State.bAtEnd;
|
||||
}
|
||||
|
||||
bool UHyperTwistViewerLibrary::IsPlaybackActive(const FHyperTwistViewerPlaybackState& State)
|
||||
{
|
||||
return State.Mode == EHyperTwistViewerPlaybackMode::Playing;
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@
|
|||
#include "Engine/GameInstance.h"
|
||||
#include "HyperTwistAlgorithm/HyperTwistAlgorithmParser.h"
|
||||
#include "HyperTwistAlgorithm/HyperTwistAlgorithmSerializer.h"
|
||||
#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h"
|
||||
#include "HyperTwistSimulation/HyperTwistViewerLibrary.h"
|
||||
#include "HyperTwistTraining/HyperTwistTrainingCoachLibrary.h"
|
||||
#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h"
|
||||
|
||||
|
|
@ -733,6 +735,37 @@ FString UHyperTwistTrainingRuntimeLibrary::BuildBundledBrowserViewerQaChecklistT
|
|||
return UHyperTwistTrainingViewerLibrary::BuildQaChecklistTsv(GetBundledBrowserViewerReferenceBundle());
|
||||
}
|
||||
|
||||
bool UHyperTwistTrainingRuntimeLibrary::TryGetCompactReplayViewerSurface(
|
||||
const UHyperTwistPuzzleViewerComponent* ViewerComponent,
|
||||
FHyperTwistViewerRuntimeSurface& OutSurface
|
||||
)
|
||||
{
|
||||
OutSurface = FHyperTwistViewerRuntimeSurface();
|
||||
|
||||
if (!IsValid(ViewerComponent) || !ViewerComponent->HasLoadedReplayPacket())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const FHyperTwistViewerPlaybackState PlaybackState = ViewerComponent->GetPlaybackState();
|
||||
if (!PlaybackState.IsStructurallyValid())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const FHyperTwistViewerMoveEntry CurrentMoveEntry = ViewerComponent->GetCurrentMoveEntry();
|
||||
OutSurface.PlaybackState = PlaybackState;
|
||||
OutSurface.CurrentMoveEntry = CurrentMoveEntry;
|
||||
OutSurface.ReplaySummary = ViewerComponent->GetReplaySummary();
|
||||
OutSurface.ScrubberNormalizedPosition = UHyperTwistViewerLibrary::GetScrubberNormalizedPosition(PlaybackState);
|
||||
OutSurface.ControlBarReadout = UHyperTwistViewerLibrary::GetControlBarReadout(
|
||||
PlaybackState,
|
||||
CurrentMoveEntry
|
||||
);
|
||||
|
||||
return OutSurface.IsStructurallyValid();
|
||||
}
|
||||
|
||||
bool UHyperTwistTrainingRuntimeLibrary::TryGetBundledEmbodiedCompanionNarrationContract(
|
||||
const FString& ContractId,
|
||||
FHyperTwistTrainingCompanionNarrationContract& OutContract
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
#pragma once
|
||||
|
||||
// Clean-room implementation for HyperTwist puzzle viewer subsystem — Bound 1
|
||||
// Lane: cubing/twisty.js (GPL-3.0-or-later — restrictive clean-room lane)
|
||||
// Implemented from governance docs and abstract idea handoff only.
|
||||
// No source code inspection. Clean-room workflow only.
|
||||
// Date: 2026-05-17
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/ActorComponent.h"
|
||||
#include "HyperTwistReplay/HyperTwistReplayTypes.h"
|
||||
#include "HyperTwistSimulation/HyperTwistSimulationTypes.h"
|
||||
#include "HyperTwistSimulation/HyperTwistViewerTypes.h"
|
||||
#include "HyperTwistPuzzleViewerComponent.generated.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delegate — broadcast whenever playback state changes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(
|
||||
FOnViewerPlaybackStateChanged,
|
||||
const FHyperTwistViewerPlaybackState&, NewState
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
// Viewport rendering is Bound 4.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
UCLASS(ClassGroup = "HyperTwist", meta = (BlueprintSpawnableComponent), DisplayName = "HyperTwist Puzzle Viewer")
|
||||
class UNREALHYPERTWIST_API UHyperTwistPuzzleViewerComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
|
||||
UHyperTwistPuzzleViewerComponent();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Load API
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Load or replace the scene context. Resets playback state.
|
||||
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Viewer")
|
||||
void LoadSceneContext(const FHyperTwistSimulationSceneContext& InContext);
|
||||
|
||||
// Load or replace the move list. Resets the playhead to the start.
|
||||
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Viewer")
|
||||
void LoadMoveList(const TArray<FHyperTwistViewerMoveEntry>& InMoveList);
|
||||
|
||||
// Load or replace the replay packet. Derives move-list and replay summary
|
||||
// state for the compact shell.
|
||||
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Viewer")
|
||||
bool LoadReplayPacket(const FHyperTwistReplayPacket& InReplayPacket);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Playback control
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Apply a control request. Advances, retreats, or repositions the playhead.
|
||||
// Bound 1: pure state-machine. No animation is driven here.
|
||||
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Viewer")
|
||||
void ApplyControlRequest(const FHyperTwistViewerControlRequest& Request);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Readout accessors
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer")
|
||||
FHyperTwistViewerPlaybackState GetPlaybackState() const { return PlaybackState; }
|
||||
|
||||
// Returns the move entry at the current playhead position.
|
||||
// Returns a default entry if the move list is empty.
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer")
|
||||
FHyperTwistViewerMoveEntry GetCurrentMoveEntry() const;
|
||||
|
||||
// Returns the loaded scene context. Check bSceneContextLoaded before use.
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer")
|
||||
FHyperTwistSimulationSceneContext GetSceneContext() const { return SceneContext; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer")
|
||||
bool HasLoadedReplayPacket() const { return bReplayPacketLoaded; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer")
|
||||
FHyperTwistDerivedReplaySummary GetReplaySummary() const { return ReplaySummary; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Delegates
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "HyperTwist|Viewer")
|
||||
FOnViewerPlaybackStateChanged OnPlaybackStateChanged;
|
||||
|
||||
protected:
|
||||
|
||||
virtual void BeginPlay() override;
|
||||
|
||||
private:
|
||||
|
||||
UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer")
|
||||
FHyperTwistSimulationSceneContext SceneContext;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer")
|
||||
TArray<FHyperTwistViewerMoveEntry> MoveList;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer")
|
||||
FHyperTwistViewerPlaybackState PlaybackState;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer")
|
||||
FHyperTwistReplayPacket ReplayPacket;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer")
|
||||
FHyperTwistDerivedReplaySummary ReplaySummary;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, Category = "HyperTwist|Viewer")
|
||||
bool bReplayPacketLoaded = false;
|
||||
|
||||
// Rebuild the playback state derived fields and broadcast the delegate.
|
||||
void RebuildAndBroadcast();
|
||||
};
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
#pragma once
|
||||
|
||||
// Clean-room implementation for HyperTwist puzzle viewer subsystem — Bound 1
|
||||
// Lane: cubing/twisty.js (GPL-3.0-or-later — restrictive clean-room lane)
|
||||
// Implemented from governance docs and abstract idea handoff only.
|
||||
// No source code inspection. Clean-room workflow only.
|
||||
// Date: 2026-05-17
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Kismet/BlueprintFunctionLibrary.h"
|
||||
#include "HyperTwistSimulation/HyperTwistViewerTypes.h"
|
||||
#include "HyperTwistViewerLibrary.generated.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UHyperTwistViewerLibrary
|
||||
//
|
||||
// Stateless Blueprint helpers for constructing control requests, reading
|
||||
// playback state, and formatting control-bar / scrubber output.
|
||||
//
|
||||
// Bound 1 scope: construction helpers and string formatters only.
|
||||
// Easing / animation helpers belong to Bound 2.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
UCLASS()
|
||||
class UNREALHYPERTWIST_API UHyperTwistViewerLibrary : public UBlueprintFunctionLibrary
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Control request constructors
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Control")
|
||||
static FHyperTwistViewerControlRequest MakePlayRequest();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Control")
|
||||
static FHyperTwistViewerControlRequest MakePauseRequest();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Control")
|
||||
static FHyperTwistViewerControlRequest MakeStopRequest();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Control")
|
||||
static FHyperTwistViewerControlRequest MakeStepForwardRequest();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Control")
|
||||
static FHyperTwistViewerControlRequest MakeStepBackRequest();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Control")
|
||||
static FHyperTwistViewerControlRequest MakeSeekRequest(int32 TargetIndex);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Move entry construction
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer")
|
||||
static FHyperTwistViewerMoveEntry MakeMoveEntry(
|
||||
const FHyperTwistTransformation& Transform,
|
||||
const FString& DisplayLabel,
|
||||
float HintPositionSeconds
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Scrubber accessors
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Returns a normalized position in [0.0, 1.0] for the scrubber widget.
|
||||
// Returns 0.0 when the move list is empty.
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout")
|
||||
static float GetScrubberNormalizedPosition(const FHyperTwistViewerPlaybackState& State);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Label formatters
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Returns a compact status string suitable for a HUD label.
|
||||
// Example: "Playing - 3 / 10 - 0.45s / 1.25s - R"
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout")
|
||||
static FString GetControlBarReadout(
|
||||
const FHyperTwistViewerPlaybackState& State,
|
||||
const FHyperTwistViewerMoveEntry& CurrentEntry
|
||||
);
|
||||
|
||||
// Returns the display label from a move entry, or "—" if empty.
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout")
|
||||
static FString GetMoveNotationLabel(const FHyperTwistViewerMoveEntry& Entry);
|
||||
|
||||
// Returns a human-readable mode string ("Playing", "Paused", etc.).
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout")
|
||||
static FString GetPlaybackModeLabel(EHyperTwistViewerPlaybackMode Mode);
|
||||
|
||||
// Returns a "N / M" step counter string. Returns "— / —" when list is empty.
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout")
|
||||
static FString GetStepCounterLabel(const FHyperTwistViewerPlaybackState& State);
|
||||
|
||||
// Returns a "T / D" timeline string using replay-backed milliseconds.
|
||||
// Returns "-- / --" when no replay timeline is loaded.
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout")
|
||||
static FString GetTimelineLabel(const FHyperTwistViewerPlaybackState& State);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Boundary queries
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout")
|
||||
static bool IsPlaybackAtStart(const FHyperTwistViewerPlaybackState& State);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout")
|
||||
static bool IsPlaybackAtEnd(const FHyperTwistViewerPlaybackState& State);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Viewer|Readout")
|
||||
static bool IsPlaybackActive(const FHyperTwistViewerPlaybackState& State);
|
||||
};
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
#pragma once
|
||||
|
||||
// Clean-room implementation for HyperTwist puzzle viewer subsystem — Bound 1
|
||||
// Lane: cubing/twisty.js (GPL-3.0-or-later — restrictive clean-room lane)
|
||||
// Implemented from governance docs and abstract idea handoff only.
|
||||
// No source code inspection. Clean-room workflow only.
|
||||
// Date: 2026-05-17
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "HyperTwistCore/HyperTwistCoreTypes.h"
|
||||
#include "HyperTwistReplay/HyperTwistReplayTypes.h"
|
||||
#include "HyperTwistViewerTypes.generated.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Playback mode — what the viewer is currently doing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
UENUM(BlueprintType)
|
||||
enum class EHyperTwistViewerPlaybackMode : uint8
|
||||
{
|
||||
Stopped UMETA(DisplayName = "Stopped"),
|
||||
Playing UMETA(DisplayName = "Playing"),
|
||||
Paused UMETA(DisplayName = "Paused"),
|
||||
Stepping UMETA(DisplayName = "Stepping")
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control action — what the caller wants the viewer to do
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
UENUM(BlueprintType)
|
||||
enum class EHyperTwistViewerControlAction : uint8
|
||||
{
|
||||
Play UMETA(DisplayName = "Play"),
|
||||
Pause UMETA(DisplayName = "Pause"),
|
||||
Stop UMETA(DisplayName = "Stop"),
|
||||
StepForward UMETA(DisplayName = "Step Forward"),
|
||||
StepBack UMETA(DisplayName = "Step Back"),
|
||||
SeekToIndex UMETA(DisplayName = "Seek To Index")
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Move entry — a single flat move in the viewer's move list
|
||||
// HintPositionSeconds is an optional wall-clock hint for scrubber mapping.
|
||||
// Animation interpretation is deferred to Bound 2.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct UNREALHYPERTWIST_API FHyperTwistViewerMoveEntry
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
// The logical transformation this entry represents.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
FHyperTwistTransformation Transform;
|
||||
|
||||
// Short human-readable label shown in the control bar (e.g. "R", "U'", "2F2").
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
FString DisplayLabel;
|
||||
|
||||
// Optional wall-clock hint for scrubber position mapping.
|
||||
// 0.0 means no hint provided. Used by Bound 2 animation observer.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
float HintPositionSeconds = 0.0f;
|
||||
|
||||
bool IsStructurallyValid() const
|
||||
{
|
||||
return Transform.IsStructurallyValid() && !DisplayLabel.IsEmpty();
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Playback state — the viewer's current observable state
|
||||
// Produced by UHyperTwistPuzzleViewerComponent and consumed by UMG / HUD.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct UNREALHYPERTWIST_API FHyperTwistViewerPlaybackState
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
EHyperTwistViewerPlaybackMode Mode = EHyperTwistViewerPlaybackMode::Stopped;
|
||||
|
||||
// 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")
|
||||
int32 CurrentMoveIndex = INDEX_NONE;
|
||||
|
||||
// Total number of moves in the loaded list.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
int32 TotalMoveCount = 0;
|
||||
|
||||
// Replay-backed current timeline time in milliseconds.
|
||||
// Falls back to 0 when no replay packet is loaded.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
int32 CurrentTimeMs = 0;
|
||||
|
||||
// Replay-backed total timeline duration in milliseconds.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
int32 TotalTimeMs = 0;
|
||||
|
||||
// Replay-backed inspection time in milliseconds when available.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
int32 InspectionTimeMs = 0;
|
||||
|
||||
// True when CurrentMoveIndex == 0 (or list is empty).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
bool bAtStart = true;
|
||||
|
||||
// True when CurrentMoveIndex == TotalMoveCount - 1 (or list is empty).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
bool bAtEnd = true;
|
||||
|
||||
// Whether a valid scene context is currently loaded.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
bool bSceneContextLoaded = false;
|
||||
|
||||
// Whether the current shell state came from a replay packet.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
bool bReplayLoaded = false;
|
||||
|
||||
// Replay identity carried through the shell for host readout.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
FString ReplayId;
|
||||
|
||||
// Puzzle identity carried through the shell for host readout.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
FString PuzzleId;
|
||||
|
||||
// Replay result label derived from the canonical replay summary.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
FString ReplayResult;
|
||||
|
||||
// Short status label for HUD/debug display.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
FString StatusLabel;
|
||||
|
||||
bool IsStructurallyValid() const
|
||||
{
|
||||
if (TotalMoveCount < 0 || CurrentTimeMs < 0 || TotalTimeMs < 0 || InspectionTimeMs < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (TotalTimeMs > 0 && CurrentTimeMs > TotalTimeMs)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (bReplayLoaded && ReplayId.IsEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (TotalMoveCount == 0)
|
||||
{
|
||||
return CurrentMoveIndex == INDEX_NONE && bAtStart && bAtEnd;
|
||||
}
|
||||
return CurrentMoveIndex >= 0 && CurrentMoveIndex < TotalMoveCount;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control request — a single caller-driven instruction to the viewer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct UNREALHYPERTWIST_API FHyperTwistViewerControlRequest
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
EHyperTwistViewerControlAction Action = EHyperTwistViewerControlAction::Stop;
|
||||
|
||||
// Only meaningful when Action == SeekToIndex.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
int32 SeekTargetIndex = 0;
|
||||
|
||||
bool IsStructurallyValid() const
|
||||
{
|
||||
if (Action == EHyperTwistViewerControlAction::SeekToIndex)
|
||||
{
|
||||
return SeekTargetIndex >= 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime surface - minimal compact replay-player bundle exposed through the
|
||||
// training runtime library without moving shell ownership out of simulation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct UNREALHYPERTWIST_API FHyperTwistViewerRuntimeSurface
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
FHyperTwistViewerPlaybackState PlaybackState;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
FHyperTwistViewerMoveEntry CurrentMoveEntry;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
FHyperTwistDerivedReplaySummary ReplaySummary;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
float ScrubberNormalizedPosition = 0.0f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Viewer")
|
||||
FString ControlBarReadout;
|
||||
|
||||
bool IsStructurallyValid() const
|
||||
{
|
||||
return PlaybackState.IsStructurallyValid()
|
||||
&& FMath::IsFinite(ScrubberNormalizedPosition)
|
||||
&& ScrubberNormalizedPosition >= 0.0f
|
||||
&& ScrubberNormalizedPosition <= 1.0f;
|
||||
}
|
||||
};
|
||||
|
|
@ -17,9 +17,11 @@
|
|||
#include "HyperTwistTraining/HyperTwistTrainingSpatialLibrary.h"
|
||||
#include "HyperTwistTraining/HyperTwistTrainingViewerLibrary.h"
|
||||
#include "HyperTwistTraining/HyperTwistTrainingTypes.h"
|
||||
#include "HyperTwistSimulation/HyperTwistViewerTypes.h"
|
||||
#include "HyperTwistTrainingRuntimeLibrary.generated.h"
|
||||
|
||||
class UHyperTwistTrainingSubsystem;
|
||||
class UHyperTwistPuzzleViewerComponent;
|
||||
|
||||
UCLASS()
|
||||
class UNREALHYPERTWIST_API UHyperTwistTrainingRuntimeLibrary : public UBlueprintFunctionLibrary
|
||||
|
|
@ -259,6 +261,12 @@ public:
|
|||
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Viewer")
|
||||
static FString BuildBundledBrowserViewerQaChecklistTsv();
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Viewer")
|
||||
static bool TryGetCompactReplayViewerSurface(
|
||||
const UHyperTwistPuzzleViewerComponent* ViewerComponent,
|
||||
FHyperTwistViewerRuntimeSurface& OutSurface
|
||||
);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Companion")
|
||||
static bool TryGetBundledEmbodiedCompanionNarrationContract(
|
||||
const FString& ContractId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
// Copyright HyperTwist, Inc. All Rights Reserved.
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
|
||||
#include "HyperTwistBootstrap/HyperTwistContractLibrary.h"
|
||||
#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h"
|
||||
#include "HyperTwistSimulation/HyperTwistViewerLibrary.h"
|
||||
#include "HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h"
|
||||
#include "JsonObjectConverter.h"
|
||||
|
||||
#if WITH_AUTOMATION_TESTS
|
||||
|
||||
namespace HyperTwistTwistyBound1ReplayShellTestInternal
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FHyperTwistTwistyBound1ReplayShellTest,
|
||||
"HyperTwist.CleanRoom.TwistyJs.Bound1.ReplayShell",
|
||||
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
|
||||
)
|
||||
|
||||
bool FHyperTwistTwistyBound1ReplayShellTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
const FHyperTwistPuzzleState SampleState = UHyperTwistContractLibrary::MakeSampleHyperPuzzleState();
|
||||
|
||||
FHyperTwistStateSnapshot Snapshot;
|
||||
Snapshot.SnapshotId = TEXT("snapshot-001");
|
||||
Snapshot.State = SampleState;
|
||||
Snapshot.DerivedHash = TEXT("snapshot-hash-001");
|
||||
|
||||
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-prime");
|
||||
SecondMovePayload.Source = TEXT("unit-test");
|
||||
|
||||
FHyperTwistReplayPacket ReplayPacket;
|
||||
ReplayPacket.ReplayId = TEXT("replay-bound1-001");
|
||||
ReplayPacket.SessionId = TEXT("session-bound1-001");
|
||||
ReplayPacket.PuzzleDefinition = SampleState.Definition;
|
||||
ReplayPacket.CaptureMode = EHyperTwistCaptureMode::Imported;
|
||||
ReplayPacket.Events = {
|
||||
HyperTwistTwistyBound1ReplayShellTestInternal::MakeReplayEvent(
|
||||
TEXT("evt-001"),
|
||||
1,
|
||||
0,
|
||||
EHyperTwistReplayEventType::StateSnapshot,
|
||||
HyperTwistTwistyBound1ReplayShellTestInternal::SerializeStructToJson(Snapshot)
|
||||
),
|
||||
HyperTwistTwistyBound1ReplayShellTestInternal::MakeReplayEvent(
|
||||
TEXT("evt-002"),
|
||||
2,
|
||||
0,
|
||||
EHyperTwistReplayEventType::TimerStart,
|
||||
TEXT("")
|
||||
),
|
||||
HyperTwistTwistyBound1ReplayShellTestInternal::MakeReplayEvent(
|
||||
TEXT("evt-003"),
|
||||
3,
|
||||
250,
|
||||
EHyperTwistReplayEventType::Move,
|
||||
HyperTwistTwistyBound1ReplayShellTestInternal::SerializeStructToJson(FirstMovePayload)
|
||||
),
|
||||
HyperTwistTwistyBound1ReplayShellTestInternal::MakeReplayEvent(
|
||||
TEXT("evt-004"),
|
||||
4,
|
||||
500,
|
||||
EHyperTwistReplayEventType::Move,
|
||||
HyperTwistTwistyBound1ReplayShellTestInternal::SerializeStructToJson(SecondMovePayload)
|
||||
),
|
||||
HyperTwistTwistyBound1ReplayShellTestInternal::MakeReplayEvent(
|
||||
TEXT("evt-005"),
|
||||
5,
|
||||
900,
|
||||
EHyperTwistReplayEventType::SolveEnd,
|
||||
TEXT("")
|
||||
)
|
||||
};
|
||||
|
||||
UHyperTwistPuzzleViewerComponent* Viewer = NewObject<UHyperTwistPuzzleViewerComponent>();
|
||||
TestNotNull(TEXT("Viewer component instance must be created."), Viewer);
|
||||
TestTrue(TEXT("Replay packet load must succeed for a structurally valid packet."), Viewer->LoadReplayPacket(ReplayPacket));
|
||||
TestTrue(TEXT("Viewer must report a loaded replay packet after replay load."), Viewer->HasLoadedReplayPacket());
|
||||
|
||||
const FHyperTwistViewerPlaybackState InitialState = Viewer->GetPlaybackState();
|
||||
TestTrue(TEXT("Replay-backed playback state must stay structurally valid."), InitialState.IsStructurallyValid());
|
||||
TestEqual(TEXT("Replay load must expose the replay id through playback state."), InitialState.ReplayId, ReplayPacket.ReplayId);
|
||||
TestEqual(TEXT("Replay load must expose the puzzle id through playback state."), InitialState.PuzzleId, ReplayPacket.PuzzleDefinition.PuzzleId);
|
||||
TestEqual(TEXT("Replay load must derive the move count from replay events."), InitialState.TotalMoveCount, 2);
|
||||
TestEqual(TEXT("Replay load must initialize the playhead at the first move index."), InitialState.CurrentMoveIndex, 0);
|
||||
TestEqual(TEXT("Replay load must initialize current time to the start boundary."), InitialState.CurrentTimeMs, 0);
|
||||
TestEqual(TEXT("Replay load must derive total replay time from replay events."), InitialState.TotalTimeMs, 900);
|
||||
TestTrue(TEXT("Replay load should derive a scene context from a snapshot event when available."), InitialState.bSceneContextLoaded);
|
||||
TestEqual(TEXT("Initial replay-backed scrubber position must stay at the start boundary."), UHyperTwistViewerLibrary::GetScrubberNormalizedPosition(InitialState), 0.0f);
|
||||
TestEqual(TEXT("Timeline label must reflect replay-backed duration."), UHyperTwistViewerLibrary::GetTimelineLabel(InitialState), FString(TEXT("0.00s / 0.90s")));
|
||||
TestEqual(TEXT("Replay summary must count move events."), Viewer->GetReplaySummary().MoveCount, 2);
|
||||
TestEqual(TEXT("Replay summary must derive solve result state."), Viewer->GetReplaySummary().Result, FString(TEXT("completed")));
|
||||
TestTrue(
|
||||
TEXT("Replay-backed status label must include the completed replay result."),
|
||||
InitialState.StatusLabel.Contains(TEXT("completed"))
|
||||
);
|
||||
|
||||
FHyperTwistViewerRuntimeSurface InitialRuntimeSurface;
|
||||
TestTrue(
|
||||
TEXT("Training runtime library must expose the replay-backed compact viewer surface."),
|
||||
UHyperTwistTrainingRuntimeLibrary::TryGetCompactReplayViewerSurface(Viewer, InitialRuntimeSurface)
|
||||
);
|
||||
TestTrue(TEXT("Runtime surface must stay structurally valid."), InitialRuntimeSurface.IsStructurallyValid());
|
||||
TestEqual(
|
||||
TEXT("Runtime surface must preserve the replay-backed playback state."),
|
||||
InitialRuntimeSurface.PlaybackState.ReplayId,
|
||||
ReplayPacket.ReplayId
|
||||
);
|
||||
TestEqual(
|
||||
TEXT("Runtime surface must preserve replay summary move count."),
|
||||
InitialRuntimeSurface.ReplaySummary.MoveCount,
|
||||
2
|
||||
);
|
||||
TestEqual(
|
||||
TEXT("Runtime surface must expose the start-boundary scrubber position."),
|
||||
InitialRuntimeSurface.ScrubberNormalizedPosition,
|
||||
0.0f
|
||||
);
|
||||
TestTrue(
|
||||
TEXT("Runtime surface control-bar readout must include the current move label."),
|
||||
InitialRuntimeSurface.ControlBarReadout.Contains(TEXT("R"))
|
||||
);
|
||||
|
||||
Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakeSeekRequest(1));
|
||||
const FHyperTwistViewerPlaybackState SoughtState = Viewer->GetPlaybackState();
|
||||
TestEqual(TEXT("Seek must reposition the playhead within the replay-backed move list."), SoughtState.CurrentMoveIndex, 1);
|
||||
TestTrue(TEXT("Seeking to the last move must report the end boundary."), SoughtState.bAtEnd);
|
||||
TestEqual(TEXT("Seeking to the last move must advance current time to the replay end boundary."), SoughtState.CurrentTimeMs, 900);
|
||||
TestEqual(TEXT("Replay-backed scrubber should reach 1.0 at the end boundary."), UHyperTwistViewerLibrary::GetScrubberNormalizedPosition(SoughtState), 1.0f);
|
||||
|
||||
FHyperTwistViewerRuntimeSurface SoughtRuntimeSurface;
|
||||
TestTrue(
|
||||
TEXT("Training runtime library must continue to expose the replay-backed surface after seek."),
|
||||
UHyperTwistTrainingRuntimeLibrary::TryGetCompactReplayViewerSurface(Viewer, SoughtRuntimeSurface)
|
||||
);
|
||||
TestEqual(
|
||||
TEXT("Runtime surface must expose the current move label after seek."),
|
||||
SoughtRuntimeSurface.CurrentMoveEntry.DisplayLabel,
|
||||
FString(TEXT("U'"))
|
||||
);
|
||||
TestEqual(
|
||||
TEXT("Runtime surface must expose the end-boundary scrubber position after seek."),
|
||||
SoughtRuntimeSurface.ScrubberNormalizedPosition,
|
||||
1.0f
|
||||
);
|
||||
|
||||
Viewer->ApplyControlRequest(UHyperTwistViewerLibrary::MakeStopRequest());
|
||||
const FHyperTwistViewerPlaybackState StoppedState = Viewer->GetPlaybackState();
|
||||
TestEqual(TEXT("Stop must rewind the replay-backed shell to the first move index."), StoppedState.CurrentMoveIndex, 0);
|
||||
TestEqual(TEXT("Stop must rewind replay-backed current time to the start boundary."), StoppedState.CurrentTimeMs, 0);
|
||||
TestFalse(TEXT("Stop should clear the end-boundary flag after rewind."), StoppedState.bAtEnd);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_AUTOMATION_TESTS
|
||||
Loading…
Add table
Reference in a new issue