diff --git a/Resources/typewriterexample.gif b/Resources/typewriterexample.gif new file mode 100644 index 0000000..8e50c9f Binary files /dev/null and b/Resources/typewriterexample.gif differ diff --git a/Source/StevesUEHelpers/Private/StevesUI/TypewriterTextWidget.cpp b/Source/StevesUEHelpers/Private/StevesUI/TypewriterTextWidget.cpp new file mode 100644 index 0000000..6c01604 --- /dev/null +++ b/Source/StevesUEHelpers/Private/StevesUI/TypewriterTextWidget.cpp @@ -0,0 +1,314 @@ +// Original Copyright (c) Sam Bloomberg https://github.com/redxdev/UnrealRichTextDialogueBox (MIT License) + +#include "StevesUI/TypewriterTextWidget.h" +#include "Engine/Font.h" +#include "Styling/SlateStyle.h" +#include "Widgets/Text/SRichTextBlock.h" + +//PRAGMA_DISABLE_OPTIMIZATION + +TSharedRef URichTextBlockForTypewriter::RebuildWidget() +{ + // Copied from URichTextBlock::RebuildWidget + UpdateStyleData(); + + TArray< TSharedRef< class ITextDecorator > > CreatedDecorators; + CreateDecorators(CreatedDecorators); + + TextMarshaller = FRichTextLayoutMarshaller::Create(CreateMarkupParser(), CreateMarkupWriter(), CreatedDecorators, StyleInstance.Get()); + + MyRichTextBlock = + SNew(SRichTextBlock) + .TextStyle(bOverrideDefaultStyle ? &DefaultTextStyleOverride : &DefaultTextStyle) + .Marshaller(TextMarshaller) + .CreateSlateTextLayout( + FCreateSlateTextLayout::CreateWeakLambda(this, [this] (SWidget* InOwner, const FTextBlockStyle& InDefaultTextStyle) mutable + { + TextLayout = FSlateTextLayout::Create(InOwner, InDefaultTextStyle); + return StaticCastSharedPtr(TextLayout).ToSharedRef(); + })); + + return MyRichTextBlock.ToSharedRef(); +} + +UTypewriterTextWidget::UTypewriterTextWidget(const FObjectInitializer& ObjectInitializer) + : Super(ObjectInitializer) +{ + bHasFinishedPlaying = true; +} + +void UTypewriterTextWidget::SetText(const FText& InText) +{ + if (IsValid(LineText)) + { + FTimerManager& TimerManager = GetWorld()->GetTimerManager(); + TimerManager.ClearTimer(LetterTimer); + + LineText->SetText(InText); + } +} + +FText UTypewriterTextWidget::GetText() const +{ + if (IsValid(LineText)) + { + return LineText->GetText(); + } + + return FText(); +} + +void UTypewriterTextWidget::PlayLine(const FText& InLine) +{ + check(GetWorld()); + + FTimerManager& TimerManager = GetWorld()->GetTimerManager(); + TimerManager.ClearTimer(LetterTimer); + + CurrentLine = InLine; + CurrentLetterIndex = 0; + CachedLetterIndex = 0; + CurrentSegmentIndex = 0; + MaxLetterIndex = 0; + NumberOfLines = 0; + CombinedTextHeight = 0; + Segments.Empty(); + CachedSegmentText.Empty(); + + if (CurrentLine.IsEmpty()) + { + if (IsValid(LineText)) + { + LineText->SetText(FText::GetEmpty()); + } + + bHasFinishedPlaying = true; + OnTypewriterLineFinished.Broadcast(this); + OnLineFinishedPlaying(); + + SetVisibility(ESlateVisibility::Hidden); + } + else + { + if (IsValid(LineText)) + { + LineText->SetText(FText::GetEmpty()); + } + + bHasFinishedPlaying = false; + + FTimerDelegate Delegate; + Delegate.BindUObject(this, &ThisClass::PlayNextLetter); + + TimerManager.SetTimer(LetterTimer, Delegate, LetterPlayTime, true); + + SetVisibility(ESlateVisibility::SelfHitTestInvisible); + } +} + +void UTypewriterTextWidget::SkipToLineEnd() +{ + FTimerManager& TimerManager = GetWorld()->GetTimerManager(); + TimerManager.ClearTimer(LetterTimer); + + CurrentLetterIndex = MaxLetterIndex - 1; + if (IsValid(LineText)) + { + LineText->SetText(FText::FromString(CalculateSegments())); + } + + bHasFinishedPlaying = true; + OnTypewriterLineFinished.Broadcast(this); + OnLineFinishedPlaying(); +} + +void UTypewriterTextWidget::PlayNextLetter() +{ + if (Segments.Num() == 0) + { + CalculateWrappedString(); + } + + FString WrappedString = CalculateSegments(); + + // TODO: How do we keep indexing of text i18n-friendly? + if (CurrentLetterIndex < MaxLetterIndex) + { + if (IsValid(LineText)) + { + LineText->SetText(FText::FromString(WrappedString)); + } + + OnPlayLetter(); + ++CurrentLetterIndex; + } + else + { + if (IsValid(LineText)) + { + LineText->SetText(FText::FromString(CalculateSegments())); + } + + FTimerManager& TimerManager = GetWorld()->GetTimerManager(); + TimerManager.ClearTimer(LetterTimer); + + FTimerDelegate Delegate; + Delegate.BindUObject(this, &ThisClass::SkipToLineEnd); + + TimerManager.SetTimer(LetterTimer, Delegate, EndHoldTime, false); + } +} + +void UTypewriterTextWidget::CalculateWrappedString() +{ + // Rich Text views give you: + // - A blank block at the start for some reason + // - One block per line (newline characters stripped) + // - Split for different runs (decorators) + // - The newlines we add are the only newlines in the output so that's the number of lines + // If we've got here, that means the text isn't empty so 1 line at least + NumberOfLines = 1; + if (IsValid(LineText) && LineText->GetTextLayout().IsValid()) + { + TSharedPtr Layout = LineText->GetTextLayout(); + TSharedPtr Marshaller = LineText->GetTextMarshaller(); + + const FGeometry& TextBoxGeometry = LineText->GetCachedGeometry(); + const FVector2D TextBoxSize = TextBoxGeometry.GetLocalSize(); + + Layout->SetWrappingWidth(TextBoxSize.X); + Marshaller->SetText(CurrentLine.ToString(), *Layout.Get()); + Layout->UpdateIfNeeded(); + + bool bHasWrittenText = false; + auto Views = Layout->GetLineViews(); + for (int v = 0; v < Views.Num(); ++v) + { + const FTextLayout::FLineView& View = Views[v]; + + for (int b = 0; b < View.Blocks.Num(); ++b) + { + TSharedRef Block = View.Blocks[b]; + TSharedRef Run = Block->GetRun(); + + FTypewriterTextSegment Segment; + Run->AppendTextTo(Segment.Text, Block->GetTextRange()); + + // HACK: For some reason image decorators (and possibly other decorators that don't + // have actual text inside them) result in the run containing a zero width space instead of + // nothing. This messes up our checks for whether the text is empty or not, which doesn't + // have an effect on image decorators but might cause issues for other custom ones. + // Instead of emptying text, which might have some unknown effect, just mark it as empty + const bool bTextIsEmpty = Segment.Text.IsEmpty() || + (Segment.Text.Len() == 1 && Segment.Text[0] == 0x200B); + const int TextLen = bTextIsEmpty ? 0 : Segment.Text.Len(); + const bool bRunIsEmpty = Segment.RunInfo.Name.IsEmpty(); + + Segment.RunInfo = Run->GetRunInfo(); + Segments.Add(Segment); + + // A segment with a named run should still take up time for the typewriter effect. + MaxLetterIndex += FMath::Max(TextLen, Segment.RunInfo.Name.IsEmpty() ? 0 : 1); + + if (!bTextIsEmpty || !bRunIsEmpty) + { + bHasWrittenText = true; + } + } + + if (bHasWrittenText) + { + CombinedTextHeight += View.TextHeight; + } + // Add check for an unnecessary newline after ever line even if there's nothing else to do, otherwise + // we end up inserting a newline after a simple single line of text + const bool bHasMoreText = v < (Views.Num() - 1); + if (bHasWrittenText && bHasMoreText) + { + Segments.Add(FTypewriterTextSegment{TEXT("\n")}); + ++NumberOfLines; + ++MaxLetterIndex; + } + } + + Layout->SetWrappingWidth(0); + + // Set the desired vertical size so that we're already the size we need to accommodate all lines + // Without this, a flexible box size will grow as lines are added + FVector2D Sz = GetMinimumDesiredSize(); + Sz.Y = CombinedTextHeight; + SetMinimumDesiredSize(Sz); + + LineText->SetText(LineText->GetText()); + } + else + { + Segments.Add(FTypewriterTextSegment{CurrentLine.ToString()}); + MaxLetterIndex = Segments[0].Text.Len(); + } + +} + +FString UTypewriterTextWidget::CalculateSegments() +{ + FString Result = CachedSegmentText; + + int32 Idx = CachedLetterIndex; + while (Idx <= CurrentLetterIndex && CurrentSegmentIndex < Segments.Num()) + { + const FTypewriterTextSegment& Segment = Segments[CurrentSegmentIndex]; + if (!Segment.RunInfo.Name.IsEmpty()) + { + Result += FString::Printf(TEXT("<%s"), *Segment.RunInfo.Name); + + if (Segment.RunInfo.MetaData.Num() > 0) + { + for (const TTuple& MetaData : Segment.RunInfo.MetaData) + { + Result += FString::Printf(TEXT(" %s=\"%s\""), *MetaData.Key, *MetaData.Value); + } + } + + if (Segment.Text.IsEmpty()) + { + Result += TEXT("/>"); + ++Idx; // This still takes up an index for the typewriter effect. + } + else + { + Result += TEXT(">"); + } + } + + bool bIsSegmentComplete = true; + if (!Segment.Text.IsEmpty()) + { + int32 LettersLeft = CurrentLetterIndex - Idx + 1; + bIsSegmentComplete = LettersLeft >= Segment.Text.Len(); + LettersLeft = FMath::Min(LettersLeft, Segment.Text.Len()); + Idx += LettersLeft; + + Result += Segment.Text.Mid(0, LettersLeft); + + if (!Segment.RunInfo.Name.IsEmpty()) + { + Result += TEXT(""); + } + } + + if (bIsSegmentComplete) + { + CachedLetterIndex = Idx; + CachedSegmentText = Result; + ++CurrentSegmentIndex; + } + else + { + break; + } + } + + return Result; +} + +//PRAGMA_ENABLE_OPTIMIZATION \ No newline at end of file diff --git a/Source/StevesUEHelpers/Public/StevesUI/TypewriterTextWidget.h b/Source/StevesUEHelpers/Public/StevesUI/TypewriterTextWidget.h new file mode 100644 index 0000000..c06480f --- /dev/null +++ b/Source/StevesUEHelpers/Public/StevesUI/TypewriterTextWidget.h @@ -0,0 +1,131 @@ +// Original Copyright (c) Sam Bloomberg https://github.com/redxdev/UnrealRichTextDialogueBox (MIT License) +// Updates: +// 1. Fixed adding a spurious newline to single-line text +// 2. Expose line finished as event +// 3. Changed names of classes to indicate functionality better (typewriter not dialogue) +// 4. Update minimum desired size on calculate so that flexi boxes can start at the correct size +// instead of growing when the newline is added + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "Components/RichTextBlock.h" +#include "Framework/Text/RichTextLayoutMarshaller.h" +#include "Framework/Text/SlateTextLayout.h" +#include "TypewriterTextWidget.generated.h" + + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnTypewriterLineFinished, class UTypewriterTextWidget*, Widget); +/** + * A text block that exposes more information about text layout for typewriter widget. + */ +UCLASS() +class URichTextBlockForTypewriter : public URichTextBlock +{ + GENERATED_BODY() + +public: + FORCEINLINE TSharedPtr GetTextLayout() const + { + return TextLayout; + } + + FORCEINLINE TSharedPtr GetTextMarshaller() const + { + return TextMarshaller; + } + +protected: + virtual TSharedRef RebuildWidget() override; + +private: + TSharedPtr TextLayout; + TSharedPtr TextMarshaller; +}; + +UCLASS(Blueprintable) +class STEVESUEHELPERS_API UTypewriterTextWidget : public UUserWidget +{ + GENERATED_BODY() + +public: + UTypewriterTextWidget(const FObjectInitializer& ObjectInitializer); + + /// Event called when a line has finished playing, whether on its own or when skipped to end + UPROPERTY(BlueprintAssignable) + FOnTypewriterLineFinished OnTypewriterLineFinished; + + UPROPERTY(BlueprintReadOnly, meta = (BindWidget)) + URichTextBlockForTypewriter* LineText; + + // The amount of time between printing individual letters (for the "typewriter" effect). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter") + float LetterPlayTime = 0.025f; + + // The amount of time to wait after finishing the line before actually marking it completed. + // This helps prevent accidentally progressing dialogue on short lines. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter") + float EndHoldTime = 0.15f; + + /// Set Text immediately + UFUNCTION(BlueprintCallable) + void SetText(const FText& InText); + + /// Set Text immediately + UFUNCTION(BlueprintCallable) + FText GetText() const; + + + UFUNCTION(BlueprintCallable, Category = "Typewriter") + void PlayLine(const FText& InLine); + + UFUNCTION(BlueprintCallable, Category = "Typewriter") + void GetCurrentLine(FText& OutLine) const { OutLine = CurrentLine; } + + UFUNCTION(BlueprintCallable, Category = "Typewriter") + bool HasFinishedPlayingLine() const { return bHasFinishedPlaying; } + + UFUNCTION(BlueprintCallable, Category = "Typewriter") + void SkipToLineEnd(); + +protected: + UFUNCTION(BlueprintImplementableEvent, Category = "Typewriter") + void OnPlayLetter(); + + UFUNCTION(BlueprintImplementableEvent, Category = "Typewriter") + void OnLineFinishedPlaying(); + +private: + void PlayNextLetter(); + + void CalculateWrappedString(); + FString CalculateSegments(); + + UPROPERTY() + FText CurrentLine; + + struct FTypewriterTextSegment + { + FString Text; + FRunInfo RunInfo; + }; + TArray Segments; + + // The section of the text that's already been printed out and won't ever change. + // This lets us cache some of the work we've already done. We can't cache absolutely + // everything as the last few characters of a string may change if they're related to + // a named run that hasn't been completed yet. + FString CachedSegmentText; + int32 CachedLetterIndex = 0; + + int32 CurrentSegmentIndex = 0; + int32 CurrentLetterIndex = 0; + int32 MaxLetterIndex = 0; + int32 NumberOfLines = 0; + float CombinedTextHeight = 0; + + uint32 bHasFinishedPlaying : 1; + + FTimerHandle LetterTimer; +}; \ No newline at end of file diff --git a/doc/TypewriterText.md b/doc/TypewriterText.md new file mode 100644 index 0000000..bb0025d --- /dev/null +++ b/doc/TypewriterText.md @@ -0,0 +1,38 @@ +# Typewriter Rich Text Widget + +This base User Widget provides you with a rich text box that plays text like +a typewriter, one character at a time, with a configurable speed. You can +skip to the end of the text if needed. + +![Typewriter Text](../Resources/typewriterexample.gif) + +## Notable features + +* Pre-calculates line breaks so that the text doesn't start to play a word near + the end of the line then "jump down", it always knows where to start +* Pre-calculates the desired height of the text so that if embedded in a flexible + height container, the height is correct before anything is played +* Supports decorators like inline images +* Respects explicit line breaks in your text + +## Usage + +Due to a quirk of the API used for sizing, this isn't a straight `URichTextBlock` +subclass you can drop straight in. Instead, you need to: + +1. Create a Widget Blueprint, subclassed from `UTypewriterTextWidget` +2. Place within it a `URichTextBlockForTypewriter`, named `LineText` (for binding) +3. Style the nested rich text box the way you want +4. Then, in your other Widget Blueprints, make use of this `UTypewriterTextWidget` + subclass in place of a rich text box +5. Call the `Play Line` and `Skip To Line End` functions instead of `Set Text` + to make use of the typewriter effect. +6. Listen to the `On Typewriter Line Finished` event to know when the typewriter + effect has finished, or the line has been skipped to the end. + + +## Credits + +This was derived from [Sam Bloomberg's +work](https://github.com/redxdev/UnrealRichTextDialogueBox) with some +enhancements / adjustments. Both works are released under the MIT license. \ No newline at end of file diff --git a/doc/Widgets.md b/doc/Widgets.md index 15b57a1..72c23ff 100644 --- a/doc/Widgets.md +++ b/doc/Widgets.md @@ -24,6 +24,13 @@ Several custom widgets are supplied to assist with some common challenges: the input control which is bound to that input action right now. Again this dynamically switches as input method changes. +* [Typewriter Rich Text Widget](TypewriterText.md) + + A widget that allows you to use the "typewriter effect" to display text + (displaying one character at a time). Pre-calculates line breaks so text + doesn't try to fit in then jump down, and pre-calculates desired height so + your flexible boxes are always the right size *before* the text is played. + * [Menu System](Menus.md) A couple of classes to make it easy to create multi-level on-screen menus