Dialogue: Subtitles

Some of the main logic for presenting subtitles to each client. It is written in the exp language created by John Haggerty.

Exp Cheatsheet:

  • aslongas - keeps the current scope alive "as long as" the expression remains true
  • whenever - Runs the following statement repeatedly, once each time the expression is true, ending the statement when it becomes false, and waiting for it to become true again when the statement ends
  • meantime - Whenever may be paired with "meantime" to provide a statement that will be run when the expression is false
  • thread - Exp "threads" are asynchronous function calls. They are akin to coroutines in some other languages: they take turns executing and so are not truly asynchronous.
  • localthread - A localthread is a thread that is guaranteed to end when the scope it is defined in is exited. You can customize the scope it is attached to with the @ symbol.
  • cleanup - After a cleanup block is passed during execution, the statement (or block of statements) after cleanup is guaranteed to run when the scope is exited.
// lets users set their option for how many subtitles to appear
shared watchobj< int > SubtitleDisplayMode = RegisterConVar( "subtitlesEnabled", 2, "0 - no subtitles, 1 - all subtitles, 2 - no battle chatter pain efforts" )

// the actual widget for the subtitle
UCLASS()
shared struct USubtitleWidget : UUserWidget
{
    UPROPERTY(meta=(BindWidget))    UBorder? SubtitleBorder
    UPROPERTY(meta=(BindWidget))    URichTextBlock? SubtitleRichText // subtitles support different styling for the speaker name and dialogue

    int Id
    bool IsMainQueue

    watchable bool SubtitleFinished = false

    // TODO: Unreal doesn't let you Get() this?
    float CurrentRenderScale
}


shared bool
fn APlayer::ShouldAddSubtitle( AActor speaker, string subtitleText, bool isForcedRadio, ESubtitleType subtitleType )
{
    if ( !hudWidget || !isControlledLocally )
        return false

    if ( SubtitleDisplayMode == 0 )
        return false

    if ( SubtitleDisplayMode == 2 && subtitleType == ESubtitleType.BattleChatterEfforts )
        return false

    USubtitlesBoxWidget? subtitleBox = hudWidget!.SubtitlesBox
    assert( subtitleBox )

    if ( subtitleText.size <= 0 )
        return false

    if ( isForcedRadio || subtitleType == ESubtitleType.QueuedMain )
        return true

    if ( subtitleBox.CountActiveSubtitles >= MAX_ACTIVE_SUBTITLES )
        return false

    // now do expensive check for unimportant dialogue
    float distSqr = DistanceSqr( speaker.GetActorLocation(), GetActorLocation() )
    if ( distSqr >= sqr(DISTANCE_SPEAKER_TOO_FAR_FOR_SUBTITLE) )
        return false

    return true
}


// Create and hide an individual subtitle
shared maywait fn APlayer::AddSubtitleWidget( FGameplayTag speakerTag, FText subtitleText, bool isForcedRadio, ESubtitleType subtitleType, int dialogueId )
{
    aslongas @fn inPlay && isControlledLocally && hudWidget != null

    // this is the container widget for all subtitles with a gray background
    USubtitlesBoxWidget? subtitleBox = hudWidget!.SubtitlesBox
    assert( subtitleBox )

    // create subtitle widget
    UWidget? newWidget = subtitleBox.ConstructWidget( subtitleBox.SubtitleBlueprint.Get(), NAME_None )
    assert( newWidget is USubtitleWidget )
    newWidget.Id = dialogueId
    newWidget.IsMainQueue = subtitleType == ESubtitleType.QueuedMain

    // define speaker styling/name
    string speakerStyle
    if ( IsSpeakerTagPlayer(speakerTag) || IsSpeakerTagHero(speakerTag) )
        speakerStyle = SPEAKER_STYLE_FRIENDLY
    else if ( IsSpeakerTagVillain(speakerTag) )
        speakerStyle = SPEAKER_STYLE_VILLAIN
    else
        speakerStyle = SPEAKER_STYLE_DEFAULT

    string speakerName
    foreach ( tag, subtitleName in SPEAKER_NAME_MAP )
    {
        if ( UGameplayTagLibrary_MatchesTag(tag, speakerTag, true) )
        {
            speakerName = subtitleName
            break
        }
    }
    else
        assert( false, "Couldn't find subtitle name based on speaker tag." )

    if ( isForcedRadio )
        speakerName = "{speakerName} [{SUBTITLE_NAME_RADIO}]"

    // apply subtitle text
    string fullSubtitle = "<{speakerStyle}>{speakerName}:</> {subtitleText}"
    newWidget.SubtitleRichText!.SetText( fullSubtitle )

    // widget settings
    subtitleBox.SubtitleVerticalBox!.AddChildToVerticalBox( newWidget )
    newWidget.SubtitleBorder!.SetDesiredSizeScale( <1.0, 0.0> )
    newWidget.SubtitleRichText!.SetRenderOpacity( 0.0 )
    newWidget.CurrentRenderScale = 0.0
    newWidget.SetRenderTransformPivot( <0.5, 1.0> ) // So it scales from the bottom center

    UVerticalBoxSlot? slot = Cast<[ UVerticalBoxSlot ]>( newWidget.Slot )
    assert( slot )

    slot.SetHorizontalAlignment( EHorizontalAlignment.HAlign_Fill )
    slot.SetVerticalAlignment( EVerticalAlignment.VAlign_Fill )

    // add to the array
    subtitleBox.SubtitleArray.push( newWidget )
    subtitleBox.CountActiveSubtitles++

    cleanup
    {
        if ( inPlay && isControlledLocally && hudWidget != null )
        {
            subtitleBox.CountActiveSubtitles--
            newWidget.SubtitleFinished = true

            thread subtitleBox.SubtitleOutro( this, newWidget ) // fade out, then vertically shrink to 0
        }
    }

    // wait for box to fade in
    aslongas !newWidget.SubtitleFinished
        waitfor subtitleBox.IsReady

    if ( newWidget.SubtitleFinished )
        return

    // intro scale
    newWidget.SubtitleScaleTo( DURATION_SUBTITLE_SCALE, 0.0, 1.0 )

    // intro fade in
    newWidget.SubtitleRichText!.FadeTo( DURATION_SUBTITLE_FADE, 0.0, 1.0 )

    wait DURATION_MIN_SUBTITLE_DISPLAY // prevents abrupt removal

    if (dialogueId < 0)
        return

    waitfor newWidget.SubtitleFinished
}