1735 lines
57 KiB
C++
1735 lines
57 KiB
C++
#include "HyperTwistTraining/HyperTwistFirstRunLaunchWidget.h"
|
|
|
|
#include "Blueprint/WidgetTree.h"
|
|
#include "Components/Border.h"
|
|
#include "Components/Button.h"
|
|
#include "Components/ButtonSlot.h"
|
|
#include "Components/EditableTextBox.h"
|
|
#include "Components/HorizontalBox.h"
|
|
#include "Components/HorizontalBoxSlot.h"
|
|
#include "Components/Overlay.h"
|
|
#include "Components/OverlaySlot.h"
|
|
#include "Components/ScrollBox.h"
|
|
#include "Components/SizeBox.h"
|
|
#include "Components/Spacer.h"
|
|
#include "Components/TextBlock.h"
|
|
#include "Components/UniformGridPanel.h"
|
|
#include "Components/UniformGridSlot.h"
|
|
#include "Components/VerticalBox.h"
|
|
#include "Components/VerticalBoxSlot.h"
|
|
#include "Components/WidgetSwitcher.h"
|
|
#include "Dom/JsonObject.h"
|
|
#include "Engine/GameInstance.h"
|
|
#include "GenericPlatform/GenericPlatformHttp.h"
|
|
#include "HAL/PlatformTime.h"
|
|
#include "HAL/PlatformProcess.h"
|
|
#include "HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h"
|
|
#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h"
|
|
#include "HttpModule.h"
|
|
#include "Interfaces/IHttpRequest.h"
|
|
#include "Interfaces/IHttpResponse.h"
|
|
#include "Kismet/KismetSystemLibrary.h"
|
|
#include "Misc/DateTime.h"
|
|
#include "Misc/Paths.h"
|
|
#include "Serialization/JsonReader.h"
|
|
#include "Serialization/JsonSerializer.h"
|
|
#include "Styling/SlateBrush.h"
|
|
#include "HyperTwistUX/HyperTwistPlayerSettings.h"
|
|
#include "HyperTwistUX/HyperTwistSettingsPanelWidget.h"
|
|
|
|
namespace HyperTwistFirstRunWidgetInternal
|
|
{
|
|
const TCHAR* BrowserExperienceUrl = TEXT("https://hypertwist.app/app/browser-access");
|
|
const TCHAR* HelpCenterUrl = TEXT("https://hypertwist.app/support");
|
|
const TCHAR* BrowserPreviewActionId = TEXT("browser-preview");
|
|
const TCHAR* OllLearningDeckId = TEXT("oll-t");
|
|
const TCHAR* CrossLearningDeckId = TEXT("cross-4move");
|
|
const TCHAR* FiveStyleLearningDeckId = TEXT("5style-4mover");
|
|
const TCHAR* RouxLearningDeckId =
|
|
TEXT("roux-trainers-algorithm-case-deck-cmll-default");
|
|
const TCHAR* BlindfoldLearningDeckId =
|
|
TEXT("cube-trainer-algorithm-case-deck-commutator-uf-edge");
|
|
|
|
FLinearColor AvailabilityColor(const EHyperTwistPuzzleAvailability Availability)
|
|
{
|
|
switch (Availability)
|
|
{
|
|
case EHyperTwistPuzzleAvailability::Playable:
|
|
return FLinearColor(0.0f, 0.88f, 0.78f, 1.0f);
|
|
case EHyperTwistPuzzleAvailability::BrowserPreview:
|
|
return FLinearColor(1.0f, 0.62f, 0.22f, 1.0f);
|
|
case EHyperTwistPuzzleAvailability::LearningLibrary:
|
|
return FLinearColor(0.38f, 0.68f, 1.0f, 1.0f);
|
|
default:
|
|
return FLinearColor(0.56f, 0.62f, 0.68f, 1.0f);
|
|
}
|
|
}
|
|
|
|
UTextBlock* MakeText(
|
|
UWidgetTree* WidgetTree,
|
|
const FString& Text,
|
|
const int32 FontSize,
|
|
const FLinearColor& Color,
|
|
const FName& WidgetName = NAME_None
|
|
)
|
|
{
|
|
if (WidgetTree == nullptr)
|
|
{
|
|
return nullptr;
|
|
}
|
|
|
|
UTextBlock* TextBlock = WidgetTree->ConstructWidget<UTextBlock>(
|
|
UTextBlock::StaticClass(),
|
|
WidgetName);
|
|
TextBlock->SetText(FText::FromString(Text));
|
|
TextBlock->SetAutoWrapText(true);
|
|
TextBlock->SetColorAndOpacity(FSlateColor(Color));
|
|
FSlateFontInfo Font = TextBlock->GetFont();
|
|
Font.Size = FontSize;
|
|
TextBlock->SetFont(Font);
|
|
return TextBlock;
|
|
}
|
|
|
|
void AddVerticalText(
|
|
UVerticalBox* Parent,
|
|
UTextBlock* Text,
|
|
const FMargin& Padding = FMargin(0.0f, 2.0f)
|
|
)
|
|
{
|
|
if (Parent == nullptr || Text == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
if (UVerticalBoxSlot* TextSlot = Parent->AddChildToVerticalBox(Text))
|
|
{
|
|
TextSlot->SetPadding(Padding);
|
|
}
|
|
}
|
|
}
|
|
|
|
void UHyperTwistPuzzleCardWidget::ConfigureCatalogEntry(
|
|
const FHyperTwistPuzzleCatalogEntry& InEntry
|
|
)
|
|
{
|
|
Entry = InEntry;
|
|
ActionId = Entry.LaunchRouteId.IsEmpty()
|
|
? HyperTwistFirstRunWidgetInternal::BrowserPreviewActionId
|
|
: Entry.LaunchRouteId;
|
|
EnsureWidgetTreeBuilt();
|
|
RefreshFromCatalogEntry();
|
|
}
|
|
|
|
void UHyperTwistPuzzleCardWidget::RefreshFromCatalogEntry()
|
|
{
|
|
if (AvailabilityText != nullptr)
|
|
{
|
|
AvailabilityText->SetText(FText::FromString(
|
|
FString::Printf(TEXT("%s | %s"), *Entry.Dimensionality, *Entry.AvailabilityLabel)));
|
|
AvailabilityText->SetColorAndOpacity(FSlateColor(
|
|
HyperTwistFirstRunWidgetInternal::AvailabilityColor(Entry.Availability)));
|
|
}
|
|
if (TitleText != nullptr)
|
|
{
|
|
TitleText->SetText(FText::FromString(Entry.Title));
|
|
}
|
|
if (DescriptionText != nullptr)
|
|
{
|
|
DescriptionText->SetText(FText::FromString(Entry.Description));
|
|
}
|
|
if (TagsText != nullptr)
|
|
{
|
|
TagsText->SetText(FText::FromString(FString::Join(Entry.ExperienceTags, TEXT(" / "))));
|
|
}
|
|
if (LaunchButtonText != nullptr)
|
|
{
|
|
LaunchButtonText->SetText(FText::FromString(
|
|
Entry.Availability == EHyperTwistPuzzleAvailability::Playable
|
|
? TEXT("ENTER PUZZLE")
|
|
: Entry.Availability == EHyperTwistPuzzleAvailability::BrowserPreview
|
|
? TEXT("OPEN BROWSER LAB")
|
|
: TEXT("UNAVAILABLE")));
|
|
}
|
|
if (LaunchButton != nullptr)
|
|
{
|
|
LaunchButton->SetIsEnabled(
|
|
Entry.Availability == EHyperTwistPuzzleAvailability::Playable
|
|
|| Entry.Availability == EHyperTwistPuzzleAvailability::BrowserPreview);
|
|
}
|
|
}
|
|
|
|
TSharedRef<SWidget> UHyperTwistPuzzleCardWidget::RebuildWidget()
|
|
{
|
|
Initialize();
|
|
EnsureWidgetTreeBuilt();
|
|
RefreshFromCatalogEntry();
|
|
return Super::RebuildWidget();
|
|
}
|
|
|
|
void UHyperTwistPuzzleCardWidget::EnsureWidgetTreeBuilt()
|
|
{
|
|
if (WidgetTree == nullptr || WidgetTree->RootWidget != nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
UBorder* Card = WidgetTree->ConstructWidget<UBorder>(
|
|
UBorder::StaticClass(),
|
|
TEXT("PuzzleCard"));
|
|
Card->SetPadding(FMargin(20.0f));
|
|
Card->SetBrushColor(FLinearColor(0.034f, 0.054f, 0.078f, 0.98f));
|
|
|
|
UVerticalBox* Stack = WidgetTree->ConstructWidget<UVerticalBox>(
|
|
UVerticalBox::StaticClass(),
|
|
TEXT("PuzzleCardStack"));
|
|
Card->AddChild(Stack);
|
|
WidgetTree->RootWidget = Card;
|
|
|
|
AvailabilityText = HyperTwistFirstRunWidgetInternal::MakeText(
|
|
WidgetTree,
|
|
TEXT("PUZZLE"),
|
|
12,
|
|
FLinearColor(0.0f, 0.88f, 0.78f, 1.0f),
|
|
TEXT("PuzzleAvailabilityText"));
|
|
HyperTwistFirstRunWidgetInternal::AddVerticalText(Stack, AvailabilityText);
|
|
|
|
TitleText = HyperTwistFirstRunWidgetInternal::MakeText(
|
|
WidgetTree,
|
|
TEXT("Puzzle"),
|
|
24,
|
|
FLinearColor(0.93f, 0.97f, 1.0f, 1.0f),
|
|
TEXT("PuzzleTitleText"));
|
|
HyperTwistFirstRunWidgetInternal::AddVerticalText(
|
|
Stack,
|
|
TitleText,
|
|
FMargin(0.0f, 6.0f, 0.0f, 8.0f));
|
|
|
|
DescriptionText = HyperTwistFirstRunWidgetInternal::MakeText(
|
|
WidgetTree,
|
|
FString(),
|
|
15,
|
|
FLinearColor(0.65f, 0.75f, 0.81f, 1.0f),
|
|
TEXT("PuzzleDescriptionText"));
|
|
HyperTwistFirstRunWidgetInternal::AddVerticalText(Stack, DescriptionText);
|
|
|
|
TagsText = HyperTwistFirstRunWidgetInternal::MakeText(
|
|
WidgetTree,
|
|
FString(),
|
|
12,
|
|
FLinearColor(0.45f, 0.66f, 0.72f, 1.0f),
|
|
TEXT("PuzzleTagsText"));
|
|
HyperTwistFirstRunWidgetInternal::AddVerticalText(
|
|
Stack,
|
|
TagsText,
|
|
FMargin(0.0f, 12.0f, 0.0f, 14.0f));
|
|
|
|
LaunchButton = WidgetTree->ConstructWidget<UButton>(
|
|
UButton::StaticClass(),
|
|
TEXT("PuzzleLaunchButton"));
|
|
LaunchButton->SetBackgroundColor(FLinearColor(0.0f, 0.63f, 0.56f, 1.0f));
|
|
LaunchButton->OnClicked.AddDynamic(this, &UHyperTwistPuzzleCardWidget::HandleLaunchClicked);
|
|
LaunchButtonText = HyperTwistFirstRunWidgetInternal::MakeText(
|
|
WidgetTree,
|
|
TEXT("ENTER PUZZLE"),
|
|
13,
|
|
FLinearColor(0.005f, 0.025f, 0.026f, 1.0f),
|
|
TEXT("PuzzleLaunchButtonText"));
|
|
if (UButtonSlot* ButtonSlot = Cast<UButtonSlot>(LaunchButton->AddChild(LaunchButtonText)))
|
|
{
|
|
ButtonSlot->SetPadding(FMargin(14.0f, 9.0f));
|
|
ButtonSlot->SetHorizontalAlignment(HAlign_Center);
|
|
}
|
|
if (UVerticalBoxSlot* ButtonVerticalSlot = Stack->AddChildToVerticalBox(LaunchButton))
|
|
{
|
|
ButtonVerticalSlot->SetHorizontalAlignment(HAlign_Left);
|
|
}
|
|
}
|
|
|
|
void UHyperTwistPuzzleCardWidget::HandleLaunchClicked()
|
|
{
|
|
if (!ActionId.IsEmpty())
|
|
{
|
|
OnLaunchRequested.Broadcast(ActionId);
|
|
}
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::NativeOnInitialized()
|
|
{
|
|
Super::NativeOnInitialized();
|
|
SetIsFocusable(true);
|
|
RebuildFirstRunLaunchSurface();
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::NativeConstruct()
|
|
{
|
|
Super::NativeConstruct();
|
|
RebuildFirstRunLaunchSurface();
|
|
ShowPage(ActivePage);
|
|
RefreshAccountSurface();
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::NativeDestruct()
|
|
{
|
|
if (ActiveAccountRequest.IsValid())
|
|
{
|
|
ActiveAccountRequest->OnProcessRequestComplete().Unbind();
|
|
ActiveAccountRequest->CancelRequest();
|
|
ActiveAccountRequest.Reset();
|
|
}
|
|
Super::NativeDestruct();
|
|
}
|
|
|
|
TSharedRef<SWidget> UHyperTwistFirstRunLaunchWidget::RebuildWidget()
|
|
{
|
|
Initialize();
|
|
RebuildFirstRunLaunchSurface();
|
|
return Super::RebuildWidget();
|
|
}
|
|
|
|
FReply UHyperTwistFirstRunLaunchWidget::NativeOnKeyDown(
|
|
const FGeometry& InGeometry,
|
|
const FKeyEvent& InKeyEvent
|
|
)
|
|
{
|
|
if (InKeyEvent.GetKey() == EKeys::Escape && ActivePage != EHyperTwistMainMenuPage::Home)
|
|
{
|
|
ShowPage(EHyperTwistMainMenuPage::Home);
|
|
return FReply::Handled();
|
|
}
|
|
return Super::NativeOnKeyDown(InGeometry, InKeyEvent);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::RebuildFirstRunLaunchSurface()
|
|
{
|
|
if (WidgetTree == nullptr || WidgetTree->RootWidget != nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
UOverlay* RootOverlay = WidgetTree->ConstructWidget<UOverlay>(
|
|
UOverlay::StaticClass(),
|
|
TEXT("HyperTwistFirstRunRoot"));
|
|
WidgetTree->RootWidget = RootOverlay;
|
|
|
|
UBorder* Background = WidgetTree->ConstructWidget<UBorder>(
|
|
UBorder::StaticClass(),
|
|
TEXT("MainMenuBackground"));
|
|
Background->SetBrushColor(BackgroundColor);
|
|
if (UOverlaySlot* BackgroundSlot = RootOverlay->AddChildToOverlay(Background))
|
|
{
|
|
BackgroundSlot->SetHorizontalAlignment(HAlign_Fill);
|
|
BackgroundSlot->SetVerticalAlignment(VAlign_Fill);
|
|
}
|
|
|
|
UHorizontalBox* Shell = WidgetTree->ConstructWidget<UHorizontalBox>(
|
|
UHorizontalBox::StaticClass(),
|
|
TEXT("MainMenuShell"));
|
|
if (UOverlaySlot* ShellSlot = RootOverlay->AddChildToOverlay(Shell))
|
|
{
|
|
ShellSlot->SetPadding(FMargin(24.0f));
|
|
ShellSlot->SetHorizontalAlignment(HAlign_Fill);
|
|
ShellSlot->SetVerticalAlignment(VAlign_Fill);
|
|
}
|
|
|
|
USizeBox* NavigationSize = WidgetTree->ConstructWidget<USizeBox>(
|
|
USizeBox::StaticClass(),
|
|
TEXT("MainMenuNavigationSize"));
|
|
NavigationSize->SetWidthOverride(260.0f);
|
|
UBorder* NavigationBorder = WidgetTree->ConstructWidget<UBorder>(
|
|
UBorder::StaticClass(),
|
|
TEXT("MainMenuNavigationBorder"));
|
|
NavigationBorder->SetPadding(FMargin(22.0f));
|
|
NavigationBorder->SetBrushColor(PanelColor);
|
|
UVerticalBox* NavigationColumn = WidgetTree->ConstructWidget<UVerticalBox>(
|
|
UVerticalBox::StaticClass(),
|
|
TEXT("MainMenuNavigation"));
|
|
NavigationBorder->AddChild(NavigationColumn);
|
|
NavigationSize->AddChild(NavigationBorder);
|
|
if (UHorizontalBoxSlot* NavigationSlot = Shell->AddChildToHorizontalBox(NavigationSize))
|
|
{
|
|
NavigationSlot->SetPadding(FMargin(0.0f, 0.0f, 16.0f, 0.0f));
|
|
NavigationSlot->SetVerticalAlignment(VAlign_Fill);
|
|
NavigationSlot->SetSize(FSlateChildSize(ESlateSizeRule::Automatic));
|
|
}
|
|
BuildNavigation(NavigationColumn);
|
|
|
|
UBorder* ContentBorder = WidgetTree->ConstructWidget<UBorder>(
|
|
UBorder::StaticClass(),
|
|
TEXT("MainMenuContentBorder"));
|
|
ContentBorder->SetPadding(FMargin(30.0f));
|
|
ContentBorder->SetBrushColor(FLinearColor(0.012f, 0.022f, 0.035f, 0.96f));
|
|
if (UHorizontalBoxSlot* ContentSlot = Shell->AddChildToHorizontalBox(ContentBorder))
|
|
{
|
|
ContentSlot->SetHorizontalAlignment(HAlign_Fill);
|
|
ContentSlot->SetVerticalAlignment(VAlign_Fill);
|
|
ContentSlot->SetSize(FSlateChildSize(ESlateSizeRule::Fill));
|
|
}
|
|
|
|
UVerticalBox* ContentStack = WidgetTree->ConstructWidget<UVerticalBox>(
|
|
UVerticalBox::StaticClass(),
|
|
TEXT("MainMenuContentStack"));
|
|
ContentBorder->AddChild(ContentStack);
|
|
CurrentPageTitle = AddTextLine(
|
|
ContentStack,
|
|
TEXT("HOME"),
|
|
12,
|
|
AccentColor,
|
|
TEXT("CurrentPageTitle"));
|
|
AddSpacer(ContentStack, 10.0f);
|
|
|
|
PageSwitcher = WidgetTree->ConstructWidget<UWidgetSwitcher>(
|
|
UWidgetSwitcher::StaticClass(),
|
|
TEXT("MainMenuPageSwitcher"));
|
|
if (UVerticalBoxSlot* SwitcherSlot = ContentStack->AddChildToVerticalBox(PageSwitcher))
|
|
{
|
|
SwitcherSlot->SetSize(FSlateChildSize(ESlateSizeRule::Fill));
|
|
SwitcherSlot->SetHorizontalAlignment(HAlign_Fill);
|
|
SwitcherSlot->SetVerticalAlignment(VAlign_Fill);
|
|
}
|
|
|
|
UVerticalBox* HomePage = AddPage(
|
|
TEXT("HomePage"),
|
|
TEXT("THE PUZZLE UNFOLDS"),
|
|
TEXT("Think beyond the cube"),
|
|
TEXT("A complete desktop puzzle studio for classic speedcubing, exact four-dimensional cubes from 2x2x2x2 through 6x6x6x6, the 120-cell, and five-dimensional training."));
|
|
BuildHomePage(HomePage);
|
|
|
|
UVerticalBox* PuzzlePage = AddPage(
|
|
TEXT("PuzzlesPage"),
|
|
TEXT("PUZZLE LIBRARY"),
|
|
TEXT("Choose your dimension"),
|
|
TEXT("Every playable card below resolves to an owned runtime and dedicated launch route. Start familiar, then move outward one dimension at a time."));
|
|
BuildPuzzlePage(PuzzlePage);
|
|
|
|
UVerticalBox* LearnPage = AddPage(
|
|
TEXT("LearnPage"),
|
|
TEXT("LEARNING STUDIO"),
|
|
TEXT("Practice with structure"),
|
|
TEXT("Follow guided sessions, build recognition, inspect projections, and use the coach only when you want help."));
|
|
BuildLearnPage(LearnPage);
|
|
|
|
UVerticalBox* SettingsPage = AddPage(
|
|
TEXT("SettingsPage"),
|
|
TEXT("CONTROL CENTER"),
|
|
TEXT("Make HyperTwist yours"),
|
|
TEXT("Controls, sound, graphics, accessibility, speech, and AI-provider choices persist across every puzzle."));
|
|
BuildSettingsPage(SettingsPage);
|
|
|
|
UVerticalBox* AccountPage = AddPage(
|
|
TEXT("AccountPage"),
|
|
TEXT("ACCOUNT"),
|
|
TEXT("Connect browser and desktop"),
|
|
TEXT("Use a short-lived token from your signed-in browser dashboard. HyperTwist never asks for your website password inside the game."));
|
|
BuildAccountPage(AccountPage);
|
|
|
|
UVerticalBox* AdvancedPage = AddPage(
|
|
TEXT("AdvancedPage"),
|
|
TEXT("ADVANCED"),
|
|
TEXT("Diagnostics and optional hardware"),
|
|
TEXT("Engineering views and XR checks live here so the everyday puzzle experience stays clear."));
|
|
BuildAdvancedPage(AdvancedPage);
|
|
|
|
UVerticalBox* AboutPage = AddPage(
|
|
TEXT("AboutPage"),
|
|
TEXT("ABOUT & CREDITS"),
|
|
TEXT("One studio, many carefully bounded foundations"),
|
|
TEXT("HyperTwist owns the player experience and runtime. This page records the open-source inspirations, clean-room lineages, and bounded support components behind it without presenting them as separate unfinished products."));
|
|
BuildAboutPage(AboutPage);
|
|
|
|
ShowPage(EHyperTwistMainMenuPage::Home);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::BuildNavigation(UVerticalBox* NavigationColumn)
|
|
{
|
|
AddTextLine(NavigationColumn, TEXT("HYPERTWIST"), 30, TextColor, TEXT("MainMenuBrand"));
|
|
AddTextLine(NavigationColumn, TEXT("HIGHER-DIMENSIONAL PUZZLE STUDIO"), 10, AccentColor);
|
|
AddSpacer(NavigationColumn, 28.0f);
|
|
|
|
UButton* HomeButton = AddNavigationButton(NavigationColumn, TEXT("Home"), TEXT("HomeNavButton"));
|
|
HomeButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::ShowHomePage);
|
|
UButton* PuzzlesButton = AddNavigationButton(NavigationColumn, TEXT("Puzzle Library"), TEXT("PuzzlesNavButton"));
|
|
PuzzlesButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::ShowPuzzlesPage);
|
|
UButton* LearnButton = AddNavigationButton(NavigationColumn, TEXT("Learn"), TEXT("LearnNavButton"));
|
|
LearnButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::ShowLearnPage);
|
|
UButton* SettingsButton = AddNavigationButton(NavigationColumn, TEXT("Settings"), TEXT("SettingsNavButton"));
|
|
SettingsButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::ShowSettingsPage);
|
|
UButton* AccountButton = AddNavigationButton(NavigationColumn, TEXT("Account"), TEXT("AccountNavButton"));
|
|
AccountButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::ShowAccountPage);
|
|
UButton* AboutButton = AddNavigationButton(NavigationColumn, TEXT("About & Credits"), TEXT("AboutNavButton"));
|
|
AboutButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::ShowAboutPage);
|
|
UButton* AdvancedButton = AddNavigationButton(NavigationColumn, TEXT("Advanced"), TEXT("AdvancedNavButton"));
|
|
AdvancedButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::ShowAdvancedPage);
|
|
|
|
USpacer* FillSpacer = WidgetTree->ConstructWidget<USpacer>(USpacer::StaticClass());
|
|
if (UVerticalBoxSlot* FillSlot = NavigationColumn->AddChildToVerticalBox(FillSpacer))
|
|
{
|
|
FillSlot->SetSize(FSlateChildSize(ESlateSizeRule::Fill));
|
|
}
|
|
|
|
AddTextLine(
|
|
NavigationColumn,
|
|
TEXT("Keyboard and mouse are the complete primary path. Press Esc inside any puzzle for pause, controls, settings, or return."),
|
|
12,
|
|
MutedTextColor);
|
|
AddSpacer(NavigationColumn, 16.0f);
|
|
UButton* ExitButton = AddNavigationButton(NavigationColumn, TEXT("Exit HyperTwist"), TEXT("ExitApplicationButton"));
|
|
ExitButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::HandleExitApplication);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::BuildHomePage(UVerticalBox* Page)
|
|
{
|
|
AddSpacer(Page, 18.0f);
|
|
UButton* ClassicButton = AddActionButton(
|
|
Page,
|
|
TEXT("ENTER CLASSIC CUBE"),
|
|
TEXT("Start with responsive 3x3x3 free play, professional keyboard turns, scramble, timing, hints, replay, and orbit camera."),
|
|
TEXT("ClassicCubeRouteButton"),
|
|
true);
|
|
ClassicButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::OpenClassicCubeTraining);
|
|
|
|
UButton* PuzzlesButton = AddActionButton(
|
|
Page,
|
|
TEXT("EXPLORE THE PUZZLE LIBRARY"),
|
|
TEXT("Open 2x2x2x2, 3x3x3x3, 4x4x4x4, 5x5x5x5, 6x6x6x6, the 120-cell, 5D, and the browser-based MagicTile lab."),
|
|
TEXT("HomePuzzleLibraryButton"));
|
|
PuzzlesButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::ShowPuzzlesPage);
|
|
|
|
AddSpacer(Page, 28.0f);
|
|
AddFeatureCard(
|
|
Page,
|
|
TEXT("EXACT STATE"),
|
|
TEXT("Real turns, not decorative projections"),
|
|
TEXT("The generalized four-dimensional runtime owns every N^4 piece, every selectable layer, exact quarter-turn updates, projection, validation, and persistence boundaries."),
|
|
AccentColor);
|
|
AddFeatureCard(
|
|
Page,
|
|
TEXT("LEARN YOUR WAY"),
|
|
TEXT("Free play, guided practice, or focused inspection"),
|
|
TEXT("Start with established WCA controls, switch to mnemonic pairs, rebind every action, or slow the experience down and inspect one slice at a time."),
|
|
WarmAccentColor);
|
|
AddFeatureCard(
|
|
Page,
|
|
TEXT("DESKTOP FIRST"),
|
|
TEXT("The complete simulator lives here"),
|
|
TEXT("The browser lane supports account, lightweight preview, and continuity. Native HyperTwist owns the full puzzle catalog, richer rendering, offline play, input, persistence, and optional XR."),
|
|
FLinearColor(0.38f, 0.68f, 1.0f, 1.0f));
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::BuildPuzzlePage(UVerticalBox* Page)
|
|
{
|
|
AddSpacer(Page, 20.0f);
|
|
UUniformGridPanel* Grid = WidgetTree->ConstructWidget<UUniformGridPanel>(
|
|
UUniformGridPanel::StaticClass(),
|
|
TEXT("PuzzleCatalogGrid"));
|
|
Grid->SetMinDesiredSlotWidth(360.0f);
|
|
Grid->SetSlotPadding(FMargin(8.0f));
|
|
Page->AddChildToVerticalBox(Grid);
|
|
|
|
const TArray<FHyperTwistPuzzleCatalogEntry> Catalog =
|
|
UHyperTwistFirstRunLaunchLibrary::BuildPlayerPuzzleCatalog();
|
|
for (int32 Index = 0; Index < Catalog.Num(); ++Index)
|
|
{
|
|
UHyperTwistPuzzleCardWidget* Card = WidgetTree->ConstructWidget<UHyperTwistPuzzleCardWidget>(
|
|
UHyperTwistPuzzleCardWidget::StaticClass(),
|
|
*FString::Printf(TEXT("PuzzleCatalogCard_%02d"), Index));
|
|
Card->ConfigureCatalogEntry(Catalog[Index]);
|
|
Card->OnLaunchRequested.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleCatalogLaunchRequested);
|
|
if (UUniformGridSlot* GridSlot = Grid->AddChildToUniformGrid(Card, Index / 2, Index % 2))
|
|
{
|
|
GridSlot->SetHorizontalAlignment(HAlign_Fill);
|
|
GridSlot->SetVerticalAlignment(VAlign_Fill);
|
|
}
|
|
}
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::BuildLearnPage(UVerticalBox* Page)
|
|
{
|
|
AddSpacer(Page, 18.0f);
|
|
UButton* FollowAlongButton = AddActionButton(
|
|
Page,
|
|
TEXT("START CLASSIC FOLLOW-ALONG"),
|
|
TEXT("A guided first session with scripted scramble playback, clear next actions, and training continuity."),
|
|
TEXT("FollowAlongRouteButton"),
|
|
true);
|
|
FollowAlongButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::OpenFollowAlongTraining);
|
|
|
|
AddSpacer(Page, 22.0f);
|
|
AddTextLine(
|
|
Page,
|
|
TEXT("METHOD PROGRAMS"),
|
|
12,
|
|
AccentColor,
|
|
TEXT("LearningStudioProgramsHeading"));
|
|
AddTextLine(
|
|
Page,
|
|
TEXT("Choose a focused program, recall the case before revealing it, then grade yourself. HyperTwist records the result and advances through the deck with its retained progression policy."),
|
|
14,
|
|
MutedTextColor);
|
|
|
|
UButton* OllButton = AddActionButton(
|
|
Page,
|
|
TEXT("OLL RECOGNITION LAB"),
|
|
TEXT("Train subset-organized last-layer recognition, real setup scrambles, algorithm recall, timing, and spaced resurfacing."),
|
|
TEXT("LearningStudioOllButton"),
|
|
true);
|
|
OllButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleStartOllLearningProgram);
|
|
|
|
UButton* CrossButton = AddActionButton(
|
|
Page,
|
|
TEXT("CFOP CROSS LADDER"),
|
|
TEXT("Work through curated one-to-eight-move cross-planning tiers; this balanced entry point starts at the four-move deck."),
|
|
TEXT("LearningStudioCrossButton"));
|
|
CrossButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleStartCrossLearningProgram);
|
|
|
|
UButton* FiveStyleButton = AddActionButton(
|
|
Page,
|
|
TEXT("5-STYLE EDGE CYCLES"),
|
|
TEXT("Practice advanced mover-subset recall with hidden answers, measured response time, and durable review memory."),
|
|
TEXT("LearningStudioFiveStyleButton"));
|
|
FiveStyleButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleStartFiveStyleLearningProgram);
|
|
|
|
UButton* RouxButton = AddActionButton(
|
|
Page,
|
|
TEXT("ROUX CMLL STUDIO"),
|
|
TEXT("Study the retained CMLL default family through a clean-room, source-backed deck and the same progression engine."),
|
|
TEXT("LearningStudioRouxButton"));
|
|
RouxButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleStartRouxLearningProgram);
|
|
|
|
UButton* BlindfoldButton = AddActionButton(
|
|
Page,
|
|
TEXT("BLINDFOLD COMMUTATOR LAB"),
|
|
TEXT("Build UF-edge commutator recall with reveal-on-demand practice and persistent success/failure history."),
|
|
TEXT("LearningStudioBlindfoldButton"));
|
|
BlindfoldButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleStartBlindfoldLearningProgram);
|
|
|
|
AddSpacer(Page, 24.0f);
|
|
UBorder* ExerciseBorder = WidgetTree->ConstructWidget<UBorder>(
|
|
UBorder::StaticClass(),
|
|
TEXT("LearningStudioExercisePanel"));
|
|
ExerciseBorder->SetPadding(FMargin(20.0f));
|
|
ExerciseBorder->SetBrushColor(CardColor);
|
|
if (UVerticalBoxSlot* ExerciseSlot = Page->AddChildToVerticalBox(ExerciseBorder))
|
|
{
|
|
ExerciseSlot->SetPadding(FMargin(0.0f, 6.0f, 0.0f, 10.0f));
|
|
ExerciseSlot->SetHorizontalAlignment(HAlign_Fill);
|
|
}
|
|
|
|
UVerticalBox* ExerciseContent = WidgetTree->ConstructWidget<UVerticalBox>(
|
|
UVerticalBox::StaticClass(),
|
|
TEXT("LearningStudioExerciseContent"));
|
|
ExerciseBorder->AddChild(ExerciseContent);
|
|
LearningProgramText = AddTextLine(
|
|
ExerciseContent,
|
|
TEXT("SELECT A METHOD PROGRAM"),
|
|
20,
|
|
TextColor,
|
|
TEXT("LearningStudioProgram"));
|
|
LearningProgressText = AddTextLine(
|
|
ExerciseContent,
|
|
TEXT("Your attempts and next-case progression will appear here."),
|
|
12,
|
|
AccentColor,
|
|
TEXT("LearningStudioProgress"));
|
|
AddSpacer(ExerciseContent, 10.0f);
|
|
LearningPromptText = AddTextLine(
|
|
ExerciseContent,
|
|
TEXT("Prompt: no active case"),
|
|
18,
|
|
TextColor,
|
|
TEXT("LearningStudioPrompt"));
|
|
LearningSetupText = AddTextLine(
|
|
ExerciseContent,
|
|
TEXT("Setup: choose a program above to begin."),
|
|
13,
|
|
MutedTextColor,
|
|
TEXT("LearningStudioSetup"));
|
|
LearningAnswerText = AddTextLine(
|
|
ExerciseContent,
|
|
FString(),
|
|
17,
|
|
WarmAccentColor,
|
|
TEXT("LearningStudioAnswer"));
|
|
if (LearningAnswerText != nullptr)
|
|
{
|
|
LearningAnswerText->SetVisibility(ESlateVisibility::Collapsed);
|
|
}
|
|
LearningStatusText = AddTextLine(
|
|
ExerciseContent,
|
|
TEXT("Learning Studio is ready."),
|
|
13,
|
|
MutedTextColor,
|
|
TEXT("LearningStudioStatus"));
|
|
|
|
LearningRevealButton = AddActionButton(
|
|
ExerciseContent,
|
|
TEXT("REVEAL ANSWER"),
|
|
TEXT("Show the canonical algorithm and retained alternatives for the current case."),
|
|
TEXT("LearningStudioRevealButton"));
|
|
LearningRevealButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleRevealLearningAnswer);
|
|
LearningRememberedButton = AddActionButton(
|
|
ExerciseContent,
|
|
TEXT("I REMEMBERED"),
|
|
TEXT("Record a successful recall and advance using the deck selection policy."),
|
|
TEXT("LearningStudioRememberedButton"),
|
|
true);
|
|
LearningRememberedButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleLearningRemembered);
|
|
LearningReviewButton = AddActionButton(
|
|
ExerciseContent,
|
|
TEXT("REVIEW AGAIN"),
|
|
TEXT("Record a miss so this case receives higher resurfacing priority."),
|
|
TEXT("LearningStudioReviewButton"));
|
|
LearningReviewButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleLearningReviewAgain);
|
|
LearningRevealButton->SetIsEnabled(false);
|
|
LearningRememberedButton->SetIsEnabled(false);
|
|
LearningReviewButton->SetIsEnabled(false);
|
|
|
|
AddSpacer(Page, 24.0f);
|
|
AddFeatureCard(
|
|
Page,
|
|
TEXT("FOUNDATIONS AND TIMING"),
|
|
TEXT("Build fluency before speed"),
|
|
TEXT("The same training authority drives inspection, timing, replay, session history, local progression, and coach recommendations, so practice remains coherent when you return to the cube."),
|
|
AccentColor);
|
|
AddFeatureCard(
|
|
Page,
|
|
TEXT("DIMENSIONAL LADDER"),
|
|
TEXT("2x2x2x2 to 6x6x6x6"),
|
|
TEXT("Begin with the cell-first 2x projection, then advance through visible-slice modes whose layer count and state size grow while the interaction grammar remains consistent."),
|
|
WarmAccentColor);
|
|
AddFeatureCard(
|
|
Page,
|
|
TEXT("OPTIONAL COACH"),
|
|
TEXT("Help stays available, never in the way"),
|
|
TEXT("The fixed assistant panel belongs to the game interface and can use configured local or BYOK providers. Speech input and narration remain global opt-in settings, not permanent HUD clutter."),
|
|
FLinearColor(0.38f, 0.68f, 1.0f, 1.0f));
|
|
}
|
|
|
|
UHyperTwistTrainingSubsystem*
|
|
UHyperTwistFirstRunLaunchWidget::ResolveTrainingSubsystem() const
|
|
{
|
|
UGameInstance* GameInstance = GetGameInstance();
|
|
return GameInstance != nullptr
|
|
? GameInstance->GetSubsystem<UHyperTwistTrainingSubsystem>()
|
|
: nullptr;
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::StartLearningProgram(
|
|
const FString& ProgramTitle,
|
|
const FString& DeckId
|
|
)
|
|
{
|
|
UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem();
|
|
if (TrainingSubsystem == nullptr)
|
|
{
|
|
RefreshLearningStudioSurface(
|
|
TEXT("Learning Studio could not reach the local training service."),
|
|
true);
|
|
return;
|
|
}
|
|
|
|
FHyperTwistTrainingDeck Deck;
|
|
if (!UHyperTwistTrainingCatalogLibrary::TryFindDeckInCatalog(
|
|
UHyperTwistTrainingCatalogLibrary::MakePhase3TrainingCatalog(),
|
|
DeckId,
|
|
Deck)
|
|
|| !Deck.IsStructurallyValid())
|
|
{
|
|
RefreshLearningStudioSurface(
|
|
FString::Printf(
|
|
TEXT("%s is not available in this build's verified training catalog."),
|
|
*ProgramTitle),
|
|
true);
|
|
return;
|
|
}
|
|
|
|
EHyperTwistTrainingDeliveryMode DeliveryMode = EHyperTwistTrainingDeliveryMode::Timer;
|
|
if (!Deck.DeliveryModes.Contains(DeliveryMode))
|
|
{
|
|
DeliveryMode = Deck.DeliveryModes.Contains(EHyperTwistTrainingDeliveryMode::VirtualCube)
|
|
? EHyperTwistTrainingDeliveryMode::VirtualCube
|
|
: (Deck.DeliveryModes.IsEmpty()
|
|
? EHyperTwistTrainingDeliveryMode::Timer
|
|
: Deck.DeliveryModes[0]);
|
|
}
|
|
|
|
const FHyperTwistTrainingRunState StartedRun =
|
|
TrainingSubsystem->StartTrainingRunFromDeck(
|
|
Deck,
|
|
TEXT("local-user"),
|
|
FString::Printf(
|
|
TEXT("learning_studio_%s"),
|
|
*FGuid::NewGuid().ToString(EGuidFormats::Digits)),
|
|
DeliveryMode);
|
|
if (!StartedRun.IsStructurallyValid()
|
|
|| !StartedRun.CurrentSelection.TrainingCase.IsStructurallyValid())
|
|
{
|
|
RefreshLearningStudioSurface(
|
|
FString::Printf(TEXT("%s could not start a valid first case."), *ProgramTitle),
|
|
true);
|
|
return;
|
|
}
|
|
|
|
ActiveLearningProgramTitle = ProgramTitle;
|
|
ActiveLearningDeckId = Deck.DeckId;
|
|
bLearningAnswerRevealed = false;
|
|
LearningCaseStartedAtSeconds = FPlatformTime::Seconds();
|
|
RefreshLearningStudioSurface(
|
|
FString::Printf(TEXT("%s started. Recall the answer before revealing it."), *ProgramTitle));
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::SubmitLearningStudioAttempt(
|
|
const bool bRemembered
|
|
)
|
|
{
|
|
UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem();
|
|
if (TrainingSubsystem == nullptr || ActiveLearningDeckId.IsEmpty())
|
|
{
|
|
RefreshLearningStudioSurface(TEXT("Choose a method program before grading a case."), true);
|
|
return;
|
|
}
|
|
|
|
const FHyperTwistTrainingRunState CurrentRun = TrainingSubsystem->GetActiveRunState();
|
|
if (!CurrentRun.IsStructurallyValid()
|
|
|| CurrentRun.ActiveDeck.DeckId != ActiveLearningDeckId
|
|
|| !CurrentRun.CurrentSelection.TrainingCase.IsStructurallyValid())
|
|
{
|
|
RefreshLearningStudioSurface(TEXT("The active learning case is no longer available."), true);
|
|
return;
|
|
}
|
|
|
|
const int32 ElapsedMs = FMath::Max(
|
|
FMath::RoundToInt(
|
|
(FPlatformTime::Seconds() - LearningCaseStartedAtSeconds) * 1000.0),
|
|
1);
|
|
FHyperTwistTrainingAttempt Attempt;
|
|
Attempt.AttemptId = FString::Printf(
|
|
TEXT("learning_%s_%s"),
|
|
*CurrentRun.Session.TrainingSessionId,
|
|
*FGuid::NewGuid().ToString(EGuidFormats::Digits));
|
|
Attempt.TrainingSessionId = CurrentRun.Session.TrainingSessionId;
|
|
Attempt.CaseId = CurrentRun.CurrentSelection.TrainingCase.CaseId;
|
|
Attempt.Result = bRemembered
|
|
? EHyperTwistTrainingAttemptResult::Success
|
|
: EHyperTwistTrainingAttemptResult::Failure;
|
|
Attempt.TotalTimeMs = ElapsedMs;
|
|
Attempt.ExecutionTimeMs = ElapsedMs;
|
|
Attempt.Mistakes = bRemembered ? 0 : 1;
|
|
Attempt.CompletedAtUtc = FDateTime::UtcNow().ToIso8601();
|
|
|
|
const FHyperTwistTrainingRunStepResult StepResult =
|
|
TrainingSubsystem->SubmitTrainingAttempt(Attempt, ElapsedMs);
|
|
if (!StepResult.IsStructurallyValid())
|
|
{
|
|
RefreshLearningStudioSurface(TEXT("HyperTwist could not record this learning result."), true);
|
|
return;
|
|
}
|
|
|
|
bLearningAnswerRevealed = false;
|
|
LearningCaseStartedAtSeconds = FPlatformTime::Seconds();
|
|
RefreshLearningStudioSurface(
|
|
bRemembered
|
|
? TEXT("Remembered. Progress saved; the next case is ready.")
|
|
: TEXT("Marked for review. HyperTwist will resurface this case sooner."));
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::RefreshLearningStudioSurface(
|
|
const FString& StatusMessage,
|
|
const bool bError
|
|
)
|
|
{
|
|
UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem();
|
|
const FHyperTwistTrainingRunState RunState = TrainingSubsystem != nullptr
|
|
? TrainingSubsystem->GetActiveRunState()
|
|
: FHyperTwistTrainingRunState();
|
|
const bool bHasActiveLearningCase =
|
|
!ActiveLearningDeckId.IsEmpty()
|
|
&& RunState.IsStructurallyValid()
|
|
&& RunState.ActiveDeck.DeckId == ActiveLearningDeckId
|
|
&& RunState.CurrentSelection.TrainingCase.IsStructurallyValid();
|
|
|
|
if (LearningRevealButton != nullptr)
|
|
{
|
|
LearningRevealButton->SetIsEnabled(bHasActiveLearningCase);
|
|
}
|
|
if (LearningRememberedButton != nullptr)
|
|
{
|
|
LearningRememberedButton->SetIsEnabled(bHasActiveLearningCase);
|
|
}
|
|
if (LearningReviewButton != nullptr)
|
|
{
|
|
LearningReviewButton->SetIsEnabled(bHasActiveLearningCase);
|
|
}
|
|
|
|
if (!bHasActiveLearningCase)
|
|
{
|
|
if (LearningStatusText != nullptr && !StatusMessage.IsEmpty())
|
|
{
|
|
LearningStatusText->SetText(FText::FromString(StatusMessage));
|
|
LearningStatusText->SetColorAndOpacity(FSlateColor(
|
|
bError ? FLinearColor(1.0f, 0.36f, 0.28f, 1.0f) : MutedTextColor));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const FHyperTwistTrainingCase& TrainingCase =
|
|
RunState.CurrentSelection.TrainingCase;
|
|
if (LearningProgramText != nullptr)
|
|
{
|
|
LearningProgramText->SetText(FText::FromString(
|
|
ActiveLearningProgramTitle.IsEmpty()
|
|
? RunState.ActiveDeck.Title
|
|
: ActiveLearningProgramTitle));
|
|
}
|
|
if (LearningProgressText != nullptr)
|
|
{
|
|
LearningProgressText->SetText(FText::FromString(FString::Printf(
|
|
TEXT("%s | %d attempt%s recorded | %d case%s remain in this pass"),
|
|
*RunState.ActiveDeck.Title,
|
|
RunState.Session.AttemptCount,
|
|
RunState.Session.AttemptCount == 1 ? TEXT("") : TEXT("s"),
|
|
RunState.RemainingCaseIds.Num(),
|
|
RunState.RemainingCaseIds.Num() == 1 ? TEXT("") : TEXT("s"))));
|
|
}
|
|
if (LearningPromptText != nullptr)
|
|
{
|
|
const FString PromptLabel = TrainingCase.PromptLabel.IsEmpty()
|
|
? TrainingCase.CaseId
|
|
: TrainingCase.PromptLabel;
|
|
LearningPromptText->SetText(FText::FromString(FString::Printf(
|
|
TEXT("Recall: %s"),
|
|
*PromptLabel)));
|
|
}
|
|
if (LearningSetupText != nullptr)
|
|
{
|
|
const FString SetupText = TrainingCase.ScrambleNotation.IsEmpty()
|
|
? FString::Printf(
|
|
TEXT("Focus: %s. Recall the canonical sequence before revealing it."),
|
|
TrainingCase.SubsetId.IsEmpty() ? TEXT("full case") : *TrainingCase.SubsetId)
|
|
: FString::Printf(
|
|
TEXT("Setup scramble: %s"),
|
|
*TrainingCase.ScrambleNotation);
|
|
LearningSetupText->SetText(FText::FromString(SetupText));
|
|
}
|
|
if (LearningAnswerText != nullptr)
|
|
{
|
|
FString AnswerText = TrainingCase.CanonicalNotation.IsEmpty()
|
|
? TEXT("Answer: this case uses recognition or state recall rather than fixed notation.")
|
|
: FString::Printf(TEXT("Answer: %s"), *TrainingCase.CanonicalNotation);
|
|
if (!TrainingCase.AlternateNotations.IsEmpty())
|
|
{
|
|
AnswerText += FString::Printf(
|
|
TEXT("\nAlternative: %s"),
|
|
*TrainingCase.AlternateNotations[0]);
|
|
}
|
|
LearningAnswerText->SetText(FText::FromString(AnswerText));
|
|
LearningAnswerText->SetVisibility(
|
|
bLearningAnswerRevealed
|
|
? ESlateVisibility::HitTestInvisible
|
|
: ESlateVisibility::Collapsed);
|
|
}
|
|
if (LearningStatusText != nullptr)
|
|
{
|
|
LearningStatusText->SetText(FText::FromString(
|
|
StatusMessage.IsEmpty()
|
|
? TEXT("Case ready. Reveal only after making a genuine recall attempt.")
|
|
: StatusMessage));
|
|
LearningStatusText->SetColorAndOpacity(FSlateColor(
|
|
bError ? FLinearColor(1.0f, 0.36f, 0.28f, 1.0f) : MutedTextColor));
|
|
}
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleStartOllLearningProgram()
|
|
{
|
|
StartLearningProgram(TEXT("OLL Recognition Lab"), HyperTwistFirstRunWidgetInternal::OllLearningDeckId);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleStartCrossLearningProgram()
|
|
{
|
|
StartLearningProgram(TEXT("CFOP Cross Ladder"), HyperTwistFirstRunWidgetInternal::CrossLearningDeckId);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleStartFiveStyleLearningProgram()
|
|
{
|
|
StartLearningProgram(TEXT("5-Style Edge Cycles"), HyperTwistFirstRunWidgetInternal::FiveStyleLearningDeckId);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleStartRouxLearningProgram()
|
|
{
|
|
StartLearningProgram(TEXT("Roux CMLL Studio"), HyperTwistFirstRunWidgetInternal::RouxLearningDeckId);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleStartBlindfoldLearningProgram()
|
|
{
|
|
StartLearningProgram(
|
|
TEXT("Blindfold Commutator Lab"),
|
|
HyperTwistFirstRunWidgetInternal::BlindfoldLearningDeckId);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleRevealLearningAnswer()
|
|
{
|
|
bLearningAnswerRevealed = true;
|
|
RefreshLearningStudioSurface(
|
|
TEXT("Answer revealed. Grade the recall honestly to tune future resurfacing."));
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleLearningRemembered()
|
|
{
|
|
SubmitLearningStudioAttempt(true);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleLearningReviewAgain()
|
|
{
|
|
SubmitLearningStudioAttempt(false);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::BuildSettingsPage(UVerticalBox* Page)
|
|
{
|
|
AddSpacer(Page, 16.0f);
|
|
EmbeddedSettings = WidgetTree->ConstructWidget<UHyperTwistSettingsPanelWidget>(
|
|
UHyperTwistSettingsPanelWidget::StaticClass(),
|
|
TEXT("EmbeddedSettingsPanel"));
|
|
EmbeddedSettings->bShowCloseButton = false;
|
|
if (UVerticalBoxSlot* SettingsSlot = Page->AddChildToVerticalBox(EmbeddedSettings))
|
|
{
|
|
SettingsSlot->SetHorizontalAlignment(HAlign_Fill);
|
|
SettingsSlot->SetVerticalAlignment(VAlign_Fill);
|
|
SettingsSlot->SetSize(FSlateChildSize(ESlateSizeRule::Fill));
|
|
}
|
|
EmbeddedSettings->PrepareSettingsSurface();
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::BuildAccountPage(UVerticalBox* Page)
|
|
{
|
|
AddSpacer(Page, 18.0f);
|
|
UBorder* AccountCard = WidgetTree->ConstructWidget<UBorder>(
|
|
UBorder::StaticClass(),
|
|
TEXT("AccountStateCard"));
|
|
AccountCard->SetPadding(FMargin(20.0f));
|
|
AccountCard->SetBrushColor(CardColor);
|
|
UVerticalBox* AccountStack = WidgetTree->ConstructWidget<UVerticalBox>(
|
|
UVerticalBox::StaticClass(),
|
|
TEXT("AccountStateStack"));
|
|
AccountCard->AddChild(AccountStack);
|
|
Page->AddChildToVerticalBox(AccountCard);
|
|
|
|
AccountIdentityText = AddTextLine(
|
|
AccountStack,
|
|
TEXT("Not linked"),
|
|
22,
|
|
TextColor,
|
|
TEXT("AccountIdentityText"));
|
|
AccountAccessText = AddTextLine(
|
|
AccountStack,
|
|
TEXT("Browser account remains the source of truth."),
|
|
14,
|
|
MutedTextColor,
|
|
TEXT("AccountAccessText"));
|
|
AccountStatusText = AddTextLine(
|
|
AccountStack,
|
|
TEXT("Open the dashboard to create a one-time link token."),
|
|
13,
|
|
AccentColor,
|
|
TEXT("AccountStatusText"));
|
|
|
|
AddSpacer(Page, 16.0f);
|
|
DesktopLinkTokenInput = WidgetTree->ConstructWidget<UEditableTextBox>(
|
|
UEditableTextBox::StaticClass(),
|
|
TEXT("DesktopLinkTokenInput"));
|
|
DesktopLinkTokenInput->SetHintText(FText::FromString(
|
|
TEXT("Paste the one-time desktop-link token or full verification URL")));
|
|
DesktopLinkTokenInput->SetForegroundColor(FLinearColor(0.05f, 0.08f, 0.10f, 1.0f));
|
|
Page->AddChildToVerticalBox(DesktopLinkTokenInput);
|
|
|
|
LinkAccountButton = AddActionButton(
|
|
Page,
|
|
TEXT("LINK THIS DESKTOP"),
|
|
TEXT("Consumes the short-lived token once and stores the returned account summary locally."),
|
|
TEXT("LinkDesktopAccountButton"),
|
|
true);
|
|
LinkAccountButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::HandleLinkAccount);
|
|
|
|
UButton* WebsiteButton = AddActionButton(
|
|
Page,
|
|
TEXT("OPEN SIGNED-IN DASHBOARD"),
|
|
TEXT("Sign in, manage billing and entitlement, generate a desktop-link token, or download a release."),
|
|
TEXT("OpenAccountWebsiteButton"));
|
|
WebsiteButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleOpenAccountWebsite);
|
|
|
|
UnlinkAccountButton = AddActionButton(
|
|
Page,
|
|
TEXT("UNLINK LOCAL ACCOUNT"),
|
|
TEXT("Clears the account summary from this Windows profile. It does not delete or sign out the browser account."),
|
|
TEXT("UnlinkDesktopAccountButton"));
|
|
UnlinkAccountButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::HandleUnlinkAccount);
|
|
RefreshAccountSurface();
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::BuildAdvancedPage(UVerticalBox* Page)
|
|
{
|
|
AddSpacer(Page, 18.0f);
|
|
UButton* CoachButton = AddActionButton(
|
|
Page,
|
|
TEXT("OPEN ENGINEERING DASHBOARD"),
|
|
TEXT("Inspect deep runtime, bridge, validation, and training state. This is an advanced diagnostic surface, not the normal player interface."),
|
|
TEXT("CoachDashboardRouteButton"));
|
|
CoachButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::OpenCoachDashboard);
|
|
|
|
UButton* XrButton = AddActionButton(
|
|
Page,
|
|
TEXT("RUN OPTIONAL XR CHECK"),
|
|
TEXT("Starts the OpenXR validation route. Use keyboard and mouse for development until a supported headset is connected."),
|
|
TEXT("XrTrainingRouteButton"));
|
|
XrButton->OnClicked.AddDynamic(this, &UHyperTwistFirstRunLaunchWidget::OpenXrTrainingValidation);
|
|
|
|
UButton* DiagnosticsButton = AddActionButton(
|
|
Page,
|
|
TEXT("OPEN DIAGNOSTICS FOLDER"),
|
|
TEXT("Open Saved/Logs to inspect startup, runtime, and crash evidence without hunting through installation folders."),
|
|
TEXT("OpenDiagnosticsFolderButton"));
|
|
DiagnosticsButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleOpenDiagnosticsFolder);
|
|
|
|
UButton* BrowserButton = AddActionButton(
|
|
Page,
|
|
TEXT("OPEN BROWSER EXPERIENCE"),
|
|
TEXT("Launch the protected lightweight browser lane for account continuity and supported preview experiences."),
|
|
TEXT("OpenBrowserExperienceButton"));
|
|
BrowserButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleOpenBrowserExperience);
|
|
|
|
UButton* HelpButton = AddActionButton(
|
|
Page,
|
|
TEXT("OPEN HELP CENTER"),
|
|
TEXT("Read setup, control, package, account, and recovery guidance on hypertwist.app."),
|
|
TEXT("OpenHelpCenterButton"));
|
|
HelpButton->OnClicked.AddDynamic(
|
|
this,
|
|
&UHyperTwistFirstRunLaunchWidget::HandleOpenHelpCenter);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::BuildAboutPage(UVerticalBox* Page)
|
|
{
|
|
if (Page == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const TArray<FHyperTwistIntegratedCapability> Catalog =
|
|
UHyperTwistFirstRunLaunchLibrary::BuildIntegratedCapabilityCatalog();
|
|
int32 PermissiveCount = 0;
|
|
int32 CleanRoomCount = 0;
|
|
int32 BoundarySensitiveCount = 0;
|
|
for (const FHyperTwistIntegratedCapability& Capability : Catalog)
|
|
{
|
|
switch (Capability.LicenseLane)
|
|
{
|
|
case EHyperTwistIntegrationLicenseLane::Permissive:
|
|
++PermissiveCount;
|
|
break;
|
|
case EHyperTwistIntegrationLicenseLane::RestrictiveCleanRoom:
|
|
++CleanRoomCount;
|
|
break;
|
|
case EHyperTwistIntegrationLicenseLane::BoundarySensitive:
|
|
++BoundarySensitiveCount;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
AddSpacer(Page, 18.0f);
|
|
AddFeatureCard(
|
|
Page,
|
|
TEXT("INTEGRATION TRUTH"),
|
|
FString::Printf(TEXT("%d bounded capability lanes"), Catalog.Num()),
|
|
FString::Printf(
|
|
TEXT("%d permissive, %d restrictive clean-room, and %d boundary-sensitive lanes are represented in first-party HyperTwist owners. Playable puzzle engines are distinguished from learning services, browser support, and operator tooling."),
|
|
PermissiveCount,
|
|
CleanRoomCount,
|
|
BoundarySensitiveCount),
|
|
AccentColor);
|
|
|
|
const EHyperTwistIntegrationSurface Surfaces[] = {
|
|
EHyperTwistIntegrationSurface::NativePlayable,
|
|
EHyperTwistIntegrationSurface::NativePlayerService,
|
|
EHyperTwistIntegrationSurface::BrowserSupport,
|
|
EHyperTwistIntegrationSurface::OperatorSupport
|
|
};
|
|
for (const EHyperTwistIntegrationSurface Surface : Surfaces)
|
|
{
|
|
FString CapabilityLines;
|
|
int32 SurfaceCount = 0;
|
|
for (const FHyperTwistIntegratedCapability& Capability : Catalog)
|
|
{
|
|
if (Capability.Surface != Surface)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!CapabilityLines.IsEmpty())
|
|
{
|
|
CapabilityLines += TEXT("\n\n");
|
|
}
|
|
CapabilityLines += FString::Printf(
|
|
TEXT("%s\n%s\nCredit: %s (%s; %s)"),
|
|
*Capability.Title,
|
|
*Capability.Summary,
|
|
*Capability.CanonicalRepository,
|
|
*Capability.LicenseId,
|
|
*UHyperTwistFirstRunLaunchLibrary::GetIntegrationLicenseLaneLabel(
|
|
Capability.LicenseLane));
|
|
++SurfaceCount;
|
|
}
|
|
|
|
if (SurfaceCount == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const FLinearColor SurfaceAccent =
|
|
Surface == EHyperTwistIntegrationSurface::NativePlayable
|
|
? AccentColor
|
|
: Surface == EHyperTwistIntegrationSurface::NativePlayerService
|
|
? FLinearColor(0.38f, 0.68f, 1.0f, 1.0f)
|
|
: Surface == EHyperTwistIntegrationSurface::BrowserSupport
|
|
? WarmAccentColor
|
|
: FLinearColor(0.76f, 0.56f, 1.0f, 1.0f);
|
|
AddFeatureCard(
|
|
Page,
|
|
FString::Printf(TEXT("%d CAPABILITIES"), SurfaceCount),
|
|
UHyperTwistFirstRunLaunchLibrary::GetIntegrationSurfaceLabel(Surface),
|
|
CapabilityLines,
|
|
SurfaceAccent);
|
|
}
|
|
|
|
AddFeatureCard(
|
|
Page,
|
|
TEXT("HOW TO READ THIS PAGE"),
|
|
TEXT("Credit is not runtime ownership"),
|
|
TEXT("Permissive lanes preserve their required notices. Restrictive lanes identify clean-room lineage only: their donor source is not part of the proprietary runtime. Boundary-sensitive lanes stay behind the documented adapter, allowlist, package, attribution, or compliance boundary."),
|
|
MutedTextColor);
|
|
}
|
|
|
|
UVerticalBox* UHyperTwistFirstRunLaunchWidget::AddPage(
|
|
const FName& PageName,
|
|
const FString& Eyebrow,
|
|
const FString& Title,
|
|
const FString& Description
|
|
)
|
|
{
|
|
if (WidgetTree == nullptr || PageSwitcher == nullptr)
|
|
{
|
|
return nullptr;
|
|
}
|
|
|
|
UScrollBox* Scroll = WidgetTree->ConstructWidget<UScrollBox>(
|
|
UScrollBox::StaticClass(),
|
|
*FString::Printf(TEXT("%sScroll"), *PageName.ToString()));
|
|
UVerticalBox* Page = WidgetTree->ConstructWidget<UVerticalBox>(
|
|
UVerticalBox::StaticClass(),
|
|
PageName);
|
|
Scroll->AddChild(Page);
|
|
PageSwitcher->AddChild(Scroll);
|
|
|
|
AddTextLine(Page, Eyebrow, 12, AccentColor);
|
|
AddTextLine(Page, Title, 38, TextColor);
|
|
AddTextLine(Page, Description, 17, MutedTextColor);
|
|
return Page;
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::AddFeatureCard(
|
|
UVerticalBox* Parent,
|
|
const FString& Eyebrow,
|
|
const FString& Title,
|
|
const FString& Description,
|
|
const FLinearColor& Accent
|
|
) const
|
|
{
|
|
if (Parent == nullptr || WidgetTree == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
UBorder* Card = WidgetTree->ConstructWidget<UBorder>(UBorder::StaticClass());
|
|
Card->SetPadding(FMargin(20.0f));
|
|
Card->SetBrushColor(CardColor);
|
|
UVerticalBox* Stack = WidgetTree->ConstructWidget<UVerticalBox>(UVerticalBox::StaticClass());
|
|
Card->AddChild(Stack);
|
|
AddTextLine(Stack, Eyebrow, 11, Accent);
|
|
AddTextLine(Stack, Title, 21, TextColor);
|
|
AddTextLine(Stack, Description, 14, MutedTextColor);
|
|
if (UVerticalBoxSlot* CardSlot = Parent->AddChildToVerticalBox(Card))
|
|
{
|
|
CardSlot->SetPadding(FMargin(0.0f, 7.0f));
|
|
}
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::ShowPage(const EHyperTwistMainMenuPage Page)
|
|
{
|
|
ActivePage = Page;
|
|
if (PageSwitcher != nullptr)
|
|
{
|
|
PageSwitcher->SetActiveWidgetIndex(static_cast<int32>(Page));
|
|
}
|
|
if (CurrentPageTitle != nullptr)
|
|
{
|
|
const TCHAR* Label = TEXT("HOME");
|
|
switch (Page)
|
|
{
|
|
case EHyperTwistMainMenuPage::Puzzles:
|
|
Label = TEXT("PUZZLE LIBRARY");
|
|
break;
|
|
case EHyperTwistMainMenuPage::Learn:
|
|
Label = TEXT("LEARN");
|
|
break;
|
|
case EHyperTwistMainMenuPage::Settings:
|
|
Label = TEXT("SETTINGS");
|
|
break;
|
|
case EHyperTwistMainMenuPage::Account:
|
|
Label = TEXT("ACCOUNT");
|
|
break;
|
|
case EHyperTwistMainMenuPage::Advanced:
|
|
Label = TEXT("ADVANCED");
|
|
break;
|
|
case EHyperTwistMainMenuPage::About:
|
|
Label = TEXT("ABOUT & CREDITS");
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
CurrentPageTitle->SetText(FText::FromString(Label));
|
|
}
|
|
if (Page == EHyperTwistMainMenuPage::Settings && EmbeddedSettings != nullptr)
|
|
{
|
|
EmbeddedSettings->ReloadPreferences();
|
|
}
|
|
if (Page == EHyperTwistMainMenuPage::Account)
|
|
{
|
|
RefreshAccountSurface();
|
|
}
|
|
}
|
|
|
|
bool UHyperTwistFirstRunLaunchWidget::IsFirstRunLaunchSurfaceReady() const
|
|
{
|
|
return WidgetTree != nullptr
|
|
&& WidgetTree->RootWidget != nullptr
|
|
&& WidgetTree->FindWidget(FName(TEXT("HyperTwistFirstRunRoot"))) != nullptr
|
|
&& WidgetTree->FindWidget(FName(TEXT("MainMenuPageSwitcher"))) != nullptr
|
|
&& WidgetTree->FindWidget(FName(TEXT("HomeNavButton"))) != nullptr
|
|
&& WidgetTree->FindWidget(FName(TEXT("PuzzlesNavButton"))) != nullptr
|
|
&& WidgetTree->FindWidget(FName(TEXT("AboutNavButton"))) != nullptr
|
|
&& WidgetTree->FindWidget(FName(TEXT("SettingsPage"))) != nullptr
|
|
&& WidgetTree->FindWidget(FName(TEXT("AboutPage"))) != nullptr
|
|
&& WidgetTree->FindWidget(FName(TEXT("CoachDashboardRouteButton"))) != nullptr
|
|
&& WidgetTree->FindWidget(FName(TEXT("ClassicCubeRouteButton"))) != nullptr;
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::ShowHomePage()
|
|
{
|
|
ShowPage(EHyperTwistMainMenuPage::Home);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::ShowPuzzlesPage()
|
|
{
|
|
ShowPage(EHyperTwistMainMenuPage::Puzzles);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::ShowLearnPage()
|
|
{
|
|
ShowPage(EHyperTwistMainMenuPage::Learn);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::ShowSettingsPage()
|
|
{
|
|
ShowPage(EHyperTwistMainMenuPage::Settings);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::ShowAccountPage()
|
|
{
|
|
ShowPage(EHyperTwistMainMenuPage::Account);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::ShowAdvancedPage()
|
|
{
|
|
ShowPage(EHyperTwistMainMenuPage::Advanced);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::ShowAboutPage()
|
|
{
|
|
ShowPage(EHyperTwistMainMenuPage::About);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleCatalogLaunchRequested(const FString& RouteId)
|
|
{
|
|
if (RouteId == HyperTwistFirstRunWidgetInternal::BrowserPreviewActionId)
|
|
{
|
|
HandleOpenBrowserExperience();
|
|
return;
|
|
}
|
|
RequestRoute(RouteId);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::OpenCoachDashboard()
|
|
{
|
|
RequestRoute(TEXT("coach-dashboard"));
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::OpenClassicCubeTraining()
|
|
{
|
|
RequestRoute(TEXT("classic-cube-training"));
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::OpenFollowAlongTraining()
|
|
{
|
|
RequestRoute(TEXT("follow-along-training"));
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::OpenMagic120CellTraining()
|
|
{
|
|
RequestRoute(TEXT("magic-120-cell-training"));
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::OpenMagicCube5DTraining()
|
|
{
|
|
RequestRoute(TEXT("magic-cube-5d-training"));
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::OpenXrTrainingValidation()
|
|
{
|
|
RequestRoute(TEXT("xr-training-validation"));
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::RequestRoute(const FString& RouteId)
|
|
{
|
|
OnFirstRunRouteRequested.Broadcast(RouteId);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleOpenAccountWebsite()
|
|
{
|
|
OpenExternalUrl(UHyperTwistPlayerSettingsLibrary::GetAccountWebsiteUrl());
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleOpenBrowserExperience()
|
|
{
|
|
OpenExternalUrl(HyperTwistFirstRunWidgetInternal::BrowserExperienceUrl);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleOpenHelpCenter()
|
|
{
|
|
OpenExternalUrl(HyperTwistFirstRunWidgetInternal::HelpCenterUrl);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleLinkAccount()
|
|
{
|
|
if (DesktopLinkTokenInput == nullptr || LinkAccountButton == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
FString Token = DesktopLinkTokenInput->GetText().ToString().TrimStartAndEnd();
|
|
const int32 TokenMarker = Token.Find(TEXT("token="), ESearchCase::IgnoreCase, ESearchDir::FromStart);
|
|
if (TokenMarker != INDEX_NONE)
|
|
{
|
|
Token = Token.Mid(TokenMarker + 6);
|
|
const int32 Delimiter = Token.Find(TEXT("&"));
|
|
if (Delimiter != INDEX_NONE)
|
|
{
|
|
Token = Token.Left(Delimiter);
|
|
}
|
|
Token = FGenericPlatformHttp::UrlDecode(Token);
|
|
}
|
|
if (Token.IsEmpty() || Token.Len() > 4096)
|
|
{
|
|
RefreshAccountSurface(TEXT("Paste a valid desktop-link token from the dashboard."), true);
|
|
return;
|
|
}
|
|
|
|
if (ActiveAccountRequest.IsValid())
|
|
{
|
|
ActiveAccountRequest->OnProcessRequestComplete().Unbind();
|
|
ActiveAccountRequest->CancelRequest();
|
|
ActiveAccountRequest.Reset();
|
|
}
|
|
const FString VerifyUrl = FString::Printf(
|
|
TEXT("%s?token=%s"),
|
|
UHyperTwistPlayerSettingsLibrary::GetDesktopLinkVerifyUrl(),
|
|
*FGenericPlatformHttp::UrlEncode(Token));
|
|
ActiveAccountRequest = FHttpModule::Get().CreateRequest();
|
|
ActiveAccountRequest->SetURL(VerifyUrl);
|
|
ActiveAccountRequest->SetVerb(TEXT("GET"));
|
|
ActiveAccountRequest->SetHeader(TEXT("Accept"), TEXT("application/json"));
|
|
ActiveAccountRequest->SetTimeout(15.0f);
|
|
const TWeakObjectPtr<UHyperTwistFirstRunLaunchWidget> WeakThis(this);
|
|
ActiveAccountRequest->OnProcessRequestComplete().BindLambda(
|
|
[WeakThis](
|
|
FHttpRequestPtr CompletedRequest,
|
|
FHttpResponsePtr Response,
|
|
const bool bConnectedSuccessfully)
|
|
{
|
|
UHyperTwistFirstRunLaunchWidget* Widget = WeakThis.Get();
|
|
if (Widget == nullptr
|
|
|| Widget->ActiveAccountRequest.Get() != CompletedRequest.Get())
|
|
{
|
|
return;
|
|
}
|
|
|
|
const int32 StatusCode = Response.IsValid() ? Response->GetResponseCode() : 0;
|
|
const FString Body = Response.IsValid() ? Response->GetContentAsString() : FString();
|
|
Widget->ActiveAccountRequest.Reset();
|
|
Widget->ProcessDesktopLinkResponse(bConnectedSuccessfully, StatusCode, Body);
|
|
});
|
|
LinkAccountButton->SetIsEnabled(false);
|
|
RefreshAccountSurface(TEXT("Verifying the one-time token..."));
|
|
if (!ActiveAccountRequest->ProcessRequest())
|
|
{
|
|
ActiveAccountRequest->OnProcessRequestComplete().Unbind();
|
|
ActiveAccountRequest.Reset();
|
|
LinkAccountButton->SetIsEnabled(true);
|
|
RefreshAccountSurface(TEXT("The account verification request could not start."), true);
|
|
}
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::ProcessDesktopLinkResponse(
|
|
const bool bConnectedSuccessfully,
|
|
const int32 StatusCode,
|
|
const FString& Body
|
|
)
|
|
{
|
|
if (LinkAccountButton != nullptr)
|
|
{
|
|
LinkAccountButton->SetIsEnabled(true);
|
|
}
|
|
if (!bConnectedSuccessfully || StatusCode < 200 || StatusCode >= 300)
|
|
{
|
|
const FString Message = StatusCode == 404
|
|
? TEXT("That token is invalid, expired, or was already used. Generate a new token in the dashboard.")
|
|
: TEXT("HyperTwist could not verify the token. Check your connection and try again.");
|
|
RefreshAccountSurface(Message, true);
|
|
return;
|
|
}
|
|
|
|
TSharedPtr<FJsonObject> Json;
|
|
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Body);
|
|
bool bResponseOk = false;
|
|
if (!FJsonSerializer::Deserialize(Reader, Json)
|
|
|| !Json.IsValid()
|
|
|| !Json->TryGetBoolField(TEXT("ok"), bResponseOk)
|
|
|| !bResponseOk)
|
|
{
|
|
RefreshAccountSurface(TEXT("The account service returned an unreadable response."), true);
|
|
return;
|
|
}
|
|
|
|
FHyperTwistDesktopAccountState State;
|
|
State.bLinked = true;
|
|
if (!Json->TryGetStringField(TEXT("email"), State.Email)
|
|
|| !Json->TryGetStringField(TEXT("plan"), State.Plan)
|
|
|| !Json->TryGetStringField(TEXT("role"), State.Role)
|
|
|| !Json->TryGetStringField(TEXT("access_status"), State.AccessStatus))
|
|
{
|
|
RefreshAccountSurface(TEXT("The account response was missing required identity fields."), true);
|
|
return;
|
|
}
|
|
Json->TryGetBoolField(TEXT("can_download"), State.bCanDownload);
|
|
State.LinkedAtUtc = FDateTime::UtcNow().ToIso8601();
|
|
if (!UHyperTwistPlayerSettingsLibrary::SaveDesktopAccountState(State))
|
|
{
|
|
RefreshAccountSurface(TEXT("The account was verified but could not be stored locally."), true);
|
|
return;
|
|
}
|
|
|
|
if (DesktopLinkTokenInput != nullptr)
|
|
{
|
|
DesktopLinkTokenInput->SetText(FText::GetEmpty());
|
|
}
|
|
RefreshAccountSurface(TEXT("Desktop linked. Website billing and entitlement remain authoritative."));
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleUnlinkAccount()
|
|
{
|
|
UHyperTwistPlayerSettingsLibrary::ClearDesktopAccountState();
|
|
RefreshAccountSurface(TEXT("Local account link cleared."));
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::RefreshAccountSurface(
|
|
const FString& StatusMessage,
|
|
const bool bError
|
|
)
|
|
{
|
|
const FHyperTwistDesktopAccountState State =
|
|
UHyperTwistPlayerSettingsLibrary::LoadDesktopAccountState();
|
|
if (AccountIdentityText != nullptr)
|
|
{
|
|
AccountIdentityText->SetText(FText::FromString(
|
|
State.bLinked ? State.Email : TEXT("Not linked")));
|
|
}
|
|
if (AccountAccessText != nullptr)
|
|
{
|
|
AccountAccessText->SetText(FText::FromString(
|
|
State.bLinked
|
|
? FString::Printf(
|
|
TEXT("Plan: %s | Role: %s | Access: %s"),
|
|
*State.Plan,
|
|
*State.Role,
|
|
*State.AccessStatus)
|
|
: TEXT("Browser account remains the source of truth for plans, billing, and downloads.")));
|
|
}
|
|
if (AccountStatusText != nullptr)
|
|
{
|
|
const FString Message = !StatusMessage.IsEmpty()
|
|
? StatusMessage
|
|
: State.bLinked
|
|
? TEXT("This desktop has an account summary. Re-link whenever the browser account changes.")
|
|
: TEXT("Open the dashboard to create a one-time link token.");
|
|
AccountStatusText->SetText(FText::FromString(Message));
|
|
AccountStatusText->SetColorAndOpacity(FSlateColor(
|
|
bError ? FLinearColor(1.0f, 0.35f, 0.28f, 1.0f) : AccentColor));
|
|
}
|
|
if (UnlinkAccountButton != nullptr)
|
|
{
|
|
UnlinkAccountButton->SetIsEnabled(State.bLinked);
|
|
}
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleOpenDiagnosticsFolder()
|
|
{
|
|
const FString LogsDirectory = FPaths::ConvertRelativePathToFull(FPaths::ProjectLogDir());
|
|
FPlatformProcess::ExploreFolder(*LogsDirectory);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::HandleExitApplication()
|
|
{
|
|
UKismetSystemLibrary::QuitGame(
|
|
this,
|
|
GetOwningPlayer(),
|
|
EQuitPreference::Quit,
|
|
false);
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::OpenExternalUrl(const FString& Url) const
|
|
{
|
|
if (!Url.IsEmpty())
|
|
{
|
|
FPlatformProcess::LaunchURL(*Url, nullptr, nullptr);
|
|
}
|
|
}
|
|
|
|
UTextBlock* UHyperTwistFirstRunLaunchWidget::AddTextLine(
|
|
UVerticalBox* Parent,
|
|
const FString& Text,
|
|
const int32 FontSize,
|
|
const FLinearColor& Color,
|
|
const FName& WidgetName
|
|
) const
|
|
{
|
|
UTextBlock* TextBlock = HyperTwistFirstRunWidgetInternal::MakeText(
|
|
WidgetTree,
|
|
Text,
|
|
FontSize,
|
|
Color,
|
|
WidgetName);
|
|
HyperTwistFirstRunWidgetInternal::AddVerticalText(Parent, TextBlock);
|
|
return TextBlock;
|
|
}
|
|
|
|
UButton* UHyperTwistFirstRunLaunchWidget::AddNavigationButton(
|
|
UVerticalBox* Parent,
|
|
const FString& Label,
|
|
const FName& ButtonName
|
|
) const
|
|
{
|
|
if (Parent == nullptr || WidgetTree == nullptr)
|
|
{
|
|
return nullptr;
|
|
}
|
|
UButton* Button = WidgetTree->ConstructWidget<UButton>(UButton::StaticClass(), ButtonName);
|
|
Button->SetBackgroundColor(FLinearColor(0.032f, 0.058f, 0.078f, 1.0f));
|
|
UTextBlock* LabelText = HyperTwistFirstRunWidgetInternal::MakeText(
|
|
WidgetTree,
|
|
Label,
|
|
14,
|
|
TextColor);
|
|
if (UButtonSlot* LabelSlot = Cast<UButtonSlot>(Button->AddChild(LabelText)))
|
|
{
|
|
LabelSlot->SetPadding(FMargin(12.0f, 10.0f));
|
|
LabelSlot->SetHorizontalAlignment(HAlign_Left);
|
|
}
|
|
if (UVerticalBoxSlot* ButtonVerticalSlot = Parent->AddChildToVerticalBox(Button))
|
|
{
|
|
ButtonVerticalSlot->SetPadding(FMargin(0.0f, 3.0f));
|
|
ButtonVerticalSlot->SetHorizontalAlignment(HAlign_Fill);
|
|
}
|
|
return Button;
|
|
}
|
|
|
|
UButton* UHyperTwistFirstRunLaunchWidget::AddActionButton(
|
|
UVerticalBox* Parent,
|
|
const FString& Label,
|
|
const FString& SupportingText,
|
|
const FName& ButtonName,
|
|
const bool bPrimary
|
|
) const
|
|
{
|
|
if (Parent == nullptr || WidgetTree == nullptr)
|
|
{
|
|
return nullptr;
|
|
}
|
|
|
|
UButton* Button = WidgetTree->ConstructWidget<UButton>(UButton::StaticClass(), ButtonName);
|
|
Button->SetBackgroundColor(
|
|
bPrimary ? FLinearColor(0.0f, 0.72f, 0.64f, 1.0f) : CardColor);
|
|
UVerticalBox* Stack = WidgetTree->ConstructWidget<UVerticalBox>(UVerticalBox::StaticClass());
|
|
if (UButtonSlot* ActionContentSlot = Cast<UButtonSlot>(Button->AddChild(Stack)))
|
|
{
|
|
ActionContentSlot->SetPadding(FMargin(18.0f, 14.0f));
|
|
}
|
|
const FLinearColor PrimaryButtonText = FLinearColor(0.004f, 0.025f, 0.026f, 1.0f);
|
|
AddTextLine(Stack, Label, 16, bPrimary ? PrimaryButtonText : TextColor);
|
|
AddTextLine(
|
|
Stack,
|
|
SupportingText,
|
|
13,
|
|
bPrimary ? FLinearColor(0.02f, 0.12f, 0.11f, 1.0f) : MutedTextColor);
|
|
if (UVerticalBoxSlot* ActionVerticalSlot = Parent->AddChildToVerticalBox(Button))
|
|
{
|
|
ActionVerticalSlot->SetPadding(FMargin(0.0f, 6.0f));
|
|
ActionVerticalSlot->SetHorizontalAlignment(HAlign_Fill);
|
|
}
|
|
return Button;
|
|
}
|
|
|
|
void UHyperTwistFirstRunLaunchWidget::AddSpacer(
|
|
UVerticalBox* Parent,
|
|
const float Height
|
|
) const
|
|
{
|
|
if (Parent == nullptr || WidgetTree == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
USpacer* Spacer = WidgetTree->ConstructWidget<USpacer>(USpacer::StaticClass());
|
|
Spacer->SetSize(FVector2D(1.0f, Height));
|
|
Parent->AddChildToVerticalBox(Spacer);
|
|
}
|