In the last post, we prepared a C++ interface UCodoDialogueWidgetInterface to be used for connecting a SUDS Pro dialogue with a UserWidget. This post dives into how to implement UserWidget that makes use of UCodoDialogueWidgetInterface.

Part of the dialogue widget will be a scroll box that contains buttons with all the answer possibilities. Because Unreal does not come with a button that has a text we define our own widget class that basically only consists of a rich text element and a button:

CodoTextButton.h:

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "Components/Button.h"
#include "CodoTextButton.generated.h"

class URichTextBlock;

/**
 * Base class for buttons that shall show a text.
 */
UCLASS()
class CODOUI_API UCodoTextButton : public UUserWidget
{
	GENERATED_BODY()

public:
	// Underlying widgets in public scope in order for any user to be able to use their methods directly.

	UPROPERTY(EditAnywhere, meta=(BindWidget))
	UButton* Button;

	UPROPERTY(EditAnywhere, meta=(BindWidget))
	URichTextBlock* TextBlock;
};

Then, we can take care of the dialogue widget itself. The header consists of the declarations of all sub-widgets that make up the overall dialogue widget. Furthermore, we make sure that the dialogue widget class inherits from the ICodoDialogueWidgetInterface that we have prepared in the previous post.

One important thing to note is that all the members in this class that themselves are a Userwidget are marked up with the BindWidget meta specifier. We do this so that when we derive a Blueprint child class of UCodoDialogueWidget, Unreal knows which child widget in the Blueprint corresponds to which member variable in this C++ class.

The curiosity of this header file is the additional class UCodoChoiceForwarder. The click event of each answer option button shall be bound to a method that selects the dialogue’s answer option at the index that corresponds to the position of the button in the AnswerOptionsScrollBox. It is necessary to create a new class for this purpose because the the OnClicked delegate of a button is dynamic whereas the method USUDSDialogue::Choose has a parameter. Therefore, a direct binding is not possible. By storing the choice index in a property of this class, it is possible to offer an argument-less function that can be bound to a buttons OnClicked.

CodoDialogueWidget.h:

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "CodoDialogueWidgetInterface.h"
#include "CodoTextButton.h"
#include "Components/ScrollBox.h"
#include "Components/SizeBox.h"
#include "CodoDialogueWidget.generated.h"

class URichTextBlock;
class UInputAction;
class UInputMappingContext;
class UCodoChoiceForwarder;

/**
 * This class provides the logic for stepping through a USUDSDialogue from a widget.
 * It helps separating the logic from visuals. Any Widget Blueprint that is supposed to serve as the UI for a dialogue situation can inherit from this class.
 * The Widget BP needs to create the members marked with BindWidget. Apart from that it only needs to take care ot the visuals.
 */
UCLASS()
class CODOUI_API UCodoDialogueWidget : public UUserWidget, public ICodoDialogueWidgetInterface
{
	GENERATED_BODY()

public:
	// ------------ Widget elements ------------
	/** Text element that shows the current speaker line */
	UPROPERTY(meta=(BindWidget))
	URichTextBlock* SpeakerLineTextBlock;

	/** Text element that shows the speaker name */
	UPROPERTY(meta=(BindWidget))
	URichTextBlock* SpeakerNameTextBlock;

	/** Flexible parent widget for whatever is to be shown for the continue prompt */
	UPROPERTY(meta=(BindWidget))
	USizeBox* ContinuePromptParentSizeBox;

	/** The scroll box containing the answer options */
	UPROPERTY(meta=(BindWidget))
	UScrollBox* AnswerOptionsScrollBox;

	/** The class of the buttons that shall be used to populate the scroll box for each answer option */
	UPROPERTY(EditAnywhere)
	TSubclassOf<UCodoTextButton> AnswerButtonClass;

	// ------------ Other variables that need to be setup in BP ------------
	/** Input mapping that shall be used during the dialogue */
	UPROPERTY(EditAnywhere)
	UInputMappingContext* DialogueInputMappingContext;

	/**
	 * Input mapping context that shall be used once the dialogue is over.
	 * Having such a variable that must be set in BP is a workaround. Ideally, at the beginning of a dialogue the current input mapping contexts would be cached and restored once the dialogue is over.
	 * However, UEnhancedPlayerInput::GetAppliedInputContexts is protected and therefore not accessible from here.
	 */
	UPROPERTY(EditAnywhere)
	UInputMappingContext* NonDialogueInputMappingContext;

	/** InputAction for triggering a simple dialogue continue */
	UPROPERTY(EditAnywhere)
	UInputAction* Input_DialogueContinue;

	/** Text style to be applied when the dialogue choice has not been taken yet */
	UPROPERTY(EditAnywhere, meta = (RequiredAssetDataTags = "RowStructure=/Script/UMG.RichTextStyleRow"))
	TObjectPtr<UDataTable> TextStyleSet_NewChoice;

	/** Text style to be applied when the dialogue choice has already been taken */
	UPROPERTY(EditAnywhere, meta = (RequiredAssetDataTags = "RowStructure=/Script/UMG.RichTextStyleRow"))
	TObjectPtr<UDataTable> TextStyleSet_ChoiceAlreadyTaken;

private:
	// ------------ Internal state variables ------------
	bool bHasTakenOverInput;

	/** The current dialogue that is playing; Storing a pointer to the dialogue is necessary for the button click callbacks when a dialogue option is to be chosen. */
	UPROPERTY()
	USUDSDialogue* CachedDialogue;

	/** An array keeping a ChoiceForwarder for each button in the AnswerOptionScrollBox such that they are kept alive through Unreal's UPROPERTY system */
	UPROPERTY()
	TArray<UCodoChoiceForwarder*> ChoiceForwarders;

	// ------------ Implement the ICodoDialogueWidgetInterface ------------
public:
	virtual void InitDialogueWidget(USUDSDialogue* Dialogue, APlayerController* PlayerController, bool bShouldTakeOverInput) override;
	virtual void ShutdownDialogueWidget(USUDSDialogue* Dialogue, APlayerController* PlayerController, bool bShouldRestoreInput) override;
	virtual void ShowSpeakerLineInWidget(USUDSDialogue* Dialogue) override;

public:
	// ------------ Convenience Functions ------------
	USUDSDialogue* GetDialogue() const { return CachedDialogue; }

	void ContinueDialogue();

	// ------------ Overridden UserWidget methods ------------
	virtual void NativeConstruct() override;
};

/**
 * This class offers a method without arguments that performs a dialogue choice depending on its member variable choice index.
 * Having an argument-less method is necessary because is necessary because the the OnClicked delegate of a button is dynamic whereas the method USUDSDialogue::Choose has a parameter.
 * Therefore, a direct binding is not possible. By storing the choice index in a property of this class, it is possible to offer an argument-less function that can be bound to a buttons OnClicked.
 */
UCLASS()
class CODOUI_API UCodoChoiceForwarder : public UObject
{
	GENERATED_BODY()

public:
	/**
	 * The dialogue widget for whose current dialogue the choice needs to be taken.
	 * We store the dialogue widget (and not the dialogue) because the lifetime of the widget exceeds the lifetime of a dialogue.
	 * The choice has to be taken always for the current dialogue of DialogueWidget.
	 */
	UPROPERTY()
	UCodoDialogueWidget* DialogueWidget = nullptr;

	/* The choice index to be taken when calling Choose() */
	int ChoiceIndex = -1;

	/**
	 * Take a dialogue choice ChoiceIndex for the active dialogue of DialogueWidget.
	 * This method allows for performing a dialogue choice without needing to pass the choice parameter to the method itself.
	 */
	UFUNCTION()
	void Choose()
	{
		if (ensureMsgf(IsValid(DialogueWidget), TEXT("You must set the DialogueWidget in order to be able to use this class"))
			&& ensureMsgf(ChoiceIndex != -1, TEXT("You must set the ChoiceIndex in order to be able to use this class"))
		)
		{
			USUDSDialogue* Dialogue = DialogueWidget->GetDialogue();
			if (ensureMsgf(IsValid(Dialogue), TEXT("An answer has been chosen in the widget at a time at which there is no dialogue cached in the widget.")))
			{
				Dialogue->Choose(ChoiceIndex);
			}
		}
	}

	/**
	 * A convenience function that can be used as an alternative to a constructor with arguments.
	 * @param DialogueWidgetIn The dialogue widget for which the new choice forwarder shall work
	 * @param ChoiceIndexIn The dialogue choice that this choice forwarder selects when calling the Choose() method
	 * @return Returns a new choice forwarder with its properties set to the input arguments
	 */
	static UCodoChoiceForwarder* CreateChoiceForwarder(UCodoDialogueWidget* DialogueWidgetIn, int ChoiceIndexIn)
	{
		UCodoChoiceForwarder* NewChoiceForwarder = NewObject<UCodoChoiceForwarder>();
		NewChoiceForwarder->ChoiceIndex = ChoiceIndexIn;
		NewChoiceForwarder->DialogueWidget = DialogueWidgetIn;

		return NewChoiceForwarder;
	}
};

The cpp boils down to implementing the interface methods and hiding all widgets for the dialogue content upon creation of the UCodoDialogueWidget (until for the first time the ShowSpeakerLineInWidget is called).

CodoDialogueWidget.cpp:

#include "CodoDialogueWidget.h"

#include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h"
#include "StevesGameSubsystem.h"
#include "Components/RichTextBlock.h"

// ------------ implement the ICodoDialogueWidgetInterface ------------
void UCodoDialogueWidget::InitDialogueWidget(USUDSDialogue* Dialogue, APlayerController* PlayerController, bool bShouldTakeOverInput)
{
	ICodoDialogueWidgetInterface::InitDialogueWidget(Dialogue, PlayerController, bShouldTakeOverInput);

	// Hide the widget until something is actually to be shown (i.e. a speaker line is displayed)
	this->SetVisibility(ESlateVisibility::Hidden);

	// Cache the dialogue
	CachedDialogue = Dialogue;

	// Handle the input mapping context
	bHasTakenOverInput = false;
	if (bShouldTakeOverInput)
	{
		const ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
		UEnhancedInputLocalPlayerSubsystem* LPSubsystem = LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>();
		if (ensureMsgf(LPSubsystem, TEXT("For the CodoDialogueWidget requires enhanced input which is currently not setup properly.")))
		{
			LPSubsystem->ClearAllMappings();
			LPSubsystem->AddMappingContext(DialogueInputMappingContext, 0);
			bHasTakenOverInput = true;
			if (IsValid(GetGameInstance()))
			{
				UStevesGameSubsystem* SGSubsystem = GetGameInstance()->GetSubsystem<UStevesGameSubsystem>();
				if (SGSubsystem) SGSubsystem->NotifyEnhancedInputMappingsChanged();
			}
			UEnhancedInputComponent* InputComp = Cast<UEnhancedInputComponent>(PlayerController->InputComponent);
			InputComp->BindAction(Input_DialogueContinue, ETriggerEvent::Triggered, this, &UCodoDialogueWidget::ContinueDialogue);
		}
	}
	SetVisibility(ESlateVisibility::Visible);
	AddToViewport();
}

void UCodoDialogueWidget::ShutdownDialogueWidget(USUDSDialogue* Dialogue, APlayerController* PlayerController, bool bShouldRestoreInput)
{
	ICodoDialogueWidgetInterface::ShutdownDialogueWidget(Dialogue, PlayerController, bShouldRestoreInput);

	// restore input
	if (bHasTakenOverInput)
	{
		const ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
		UEnhancedInputLocalPlayerSubsystem* LPSubsystem = LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>();
		if (ensureMsgf(LPSubsystem, TEXT("For the CodoDialogueWidget requires enhanced input which is currently not setup properly.")))
		{
			LPSubsystem->RemoveMappingContext(DialogueInputMappingContext);
			LPSubsystem->AddMappingContext(NonDialogueInputMappingContext, 0);
			bHasTakenOverInput = true;
			if (IsValid(GetGameInstance()))
			{
				UStevesGameSubsystem* SGSubsystem = GetGameInstance()->GetSubsystem<UStevesGameSubsystem>();
				if (SGSubsystem) SGSubsystem->NotifyEnhancedInputMappingsChanged();
			}
		}
	}
	// remove reference to the dialogue
	CachedDialogue = nullptr;
	RemoveFromParent();
}

void UCodoDialogueWidget::ShowSpeakerLineInWidget(USUDSDialogue* Dialogue)
{
	ICodoDialogueWidgetInterface::ShowSpeakerLineInWidget(Dialogue);

	SpeakerNameTextBlock->SetText(Dialogue->GetSpeakerDisplayName());
	SpeakerLineTextBlock->SetText(Dialogue->GetText());

	// make sure that both speaker name and speaker line are visible
	SpeakerNameTextBlock->SetVisibility(ESlateVisibility::Visible);
	SpeakerLineTextBlock->SetVisibility(ESlateVisibility::Visible);

	// after the text
	if (Dialogue->IsSimpleContinue())
	{
		// show simple continue widget but do not offer different choices
		AnswerOptionsScrollBox->SetVisibility(ESlateVisibility::Collapsed);
		ContinuePromptParentSizeBox->SetVisibility(ESlateVisibility::Visible);
		GetOwningPlayer()->SetShowMouseCursor(false);
	}
	else
	{
		const int NumCurrentAnswerButtons = AnswerOptionsScrollBox->GetChildrenCount();

		for (int i = 0; i < Dialogue->GetNumberOfChoices(); ++i)
		{
			// might fail and return a nullptr but then the condition in the upcoming if-clause should trigger
			UCodoTextButton* CurrentAnswerButton = Cast<UCodoTextButton>(AnswerOptionsScrollBox->GetChildAt(i));
			if (i >= NumCurrentAnswerButtons)
			{
				ensure(CurrentAnswerButton == nullptr);
				// create button and put it in the scroll box
				CurrentAnswerButton = Cast<UCodoTextButton>(CreateWidget(GetOwningPlayer(), AnswerButtonClass));
				AnswerOptionsScrollBox->AddChild(CurrentAnswerButton);

				// Add callback to the button.
				// Because the USUDSDialogue::Choose has a parameter and the OnClicked delegate is dynamic, it is necessary to make a detour through a separate class.
				UCodoChoiceForwarder* NewChoiceForwarder = UCodoChoiceForwarder::CreateChoiceForwarder(this, i);
				ChoiceForwarders.Add(NewChoiceForwarder); // store object in uproperty so it is not garbage collected
				CurrentAnswerButton->Button->OnClicked.AddUniqueDynamic(NewChoiceForwarder, &UCodoChoiceForwarder::Choose);
			}

			CurrentAnswerButton->TextBlock->SetText(Dialogue->GetChoiceText(i));
			CurrentAnswerButton->TextBlock->SetTextStyleSet(Dialogue->HasChoiceIndexBeenTakenPreviously(i) ? TextStyleSet_ChoiceAlreadyTaken : TextStyleSet_NewChoice);

			// Make sure that the current button is visible - this is necessary for pre-existing buttons that had been hidden before
			CurrentAnswerButton->SetVisibility(ESlateVisibility::Visible);

			// Setup navigation rules such that navigation with arrow keys / gamepad is possible
			// Escape means: Allow the movement to continue in that direction, seeking the next navigable widget automatically.
			CurrentAnswerButton->SetAllNavigationRules(EUINavigationRule::Escape, NAME_None); // allow navigation in all direction ...
			CurrentAnswerButton->SetNavigationRuleBase(EUINavigation::Left, EUINavigationRule::Stop); // ... but forbid navigation to the left
			CurrentAnswerButton->SetNavigationRuleBase(EUINavigation::Right, EUINavigationRule::Stop); // ... but forbid navigation to the right
			if (i == 0)
			{
				CurrentAnswerButton->SetNavigationRuleBase(EUINavigation::Up, EUINavigationRule::Stop); // make navigation stop at the first element
				// The first answer option has the focus from the start
				CurrentAnswerButton->Button->SetFocus(); // set start point of navigation at first element
			}
			if (i == Dialogue->GetNumberOfChoices() - 1)
			{
				CurrentAnswerButton->SetNavigationRuleBase(EUINavigation::Down, EUINavigationRule::Stop); // make navigation stop at the last element
			}
		}
		// if there are more buttons than there are current answer possibilities, collapse them
		for (int i = Dialogue->GetNumberOfChoices(); i < NumCurrentAnswerButtons; ++i)
		{
			AnswerOptionsScrollBox->GetChildAt(i)->SetVisibility(ESlateVisibility::Collapsed);
		}

		AnswerOptionsScrollBox->ScrollToStart();
		AnswerOptionsScrollBox->SetVisibility(ESlateVisibility::Visible);
		ContinuePromptParentSizeBox->SetVisibility(ESlateVisibility::Collapsed);
		GetOwningPlayer()->SetShowMouseCursor(true);
	}
}

void UCodoDialogueWidget::ContinueDialogue()
{
	if (IsValid(CachedDialogue) && CachedDialogue->IsSimpleContinue())
	{
		CachedDialogue->Continue();
	}
}

void UCodoDialogueWidget::NativeConstruct()
{
	// hide of all children with dialogue content until there is something of the dialogue to be shown (i.e. a speaker line is received)
	SpeakerNameTextBlock->SetVisibility(ESlateVisibility::Hidden);
	SpeakerLineTextBlock->SetVisibility(ESlateVisibility::Hidden);
	ContinuePromptParentSizeBox->SetVisibility(ESlateVisibility::Hidden);
	AnswerOptionsScrollBox->SetVisibility(ESlateVisibility::Hidden);
}