48

I'm making a retro-style game with C# .NET-Framework, and for dialogue I'm using a for-statement, that prints my text letter by letter (like a typewriter-effect):

enter image description here

I'm working with different scenes, and I have a skip button (bottom right) that skips the current dialogue and passes to the next scene. My typewriter-effect automatically stops when all the text is displayed, but when I click on the skip button, it automatically skips to the next scene.

I would like it, when the typewriter is still active, and if I click on the skip button, that it first shows all the text, instead of skipping to the next scene.

So that it only skips to the next scene when all the text is displayed (automatically or manually).

This is the (working code) that I'm using for my typewriter method (+ variables):

    public string FullTextBottom;
    public string CurrentTextBottom = "";
    public bool IsActive;
    
    public async void TypeWriterEffectBottom()
    {
        if(this.BackgroundImage != null) // only runs on backgrounds that arent black
        {
            for(i=0; i < FullTextBottom.Length + 1; i++)
            {
                CurrentTextBottom = FullTextBottom.Substring(0, i); // updating current string with one extra letter
                LblTextBottom.Text = CurrentTextBottom; // "temporarily place string in text box"
                await Task.Delay(30); // wait for next update
                
                #region checks for IsActive // for debugging only!

                if(i < FullTextBottom.Length + 1)
                {
                    IsActive = true;
                    Debug1.Text = "IsActive = " + IsActive.ToString();
                }
                if(CurrentTextBottom.Length == FullTextBottom.Length)
                {
                    IsActive = false;
                    Debug1.Text = "IsActive = " + IsActive.ToString();
                }

                #endregion
            }
        }

    }

And this is the code that I want to get for my skip button (named Pb_FastForward):

    private void PbFastForward_Click(object sender, EventArgs e)
    {
        if( //typewriter is active)
        {
             //print all text into the textbox
        }
        
        else if( //all text is printed)
        {
             // skip to the next scene
        }
    }   

But I don't know how to formulate the 2nd part of code. I've tried many different approaches, like using counters that increase on a buttonclick (and using that to check in an if-statement), and many different types of if-statements to see if the typewriter is still active or not, but I haven't got anything to work yet.

Edit

This is the sequence in which different components need to be loaded (on button click), which is related to the way different variables are updated:

  1. Gamestate_Cycle() --> called for loading new scene.
  2. FullTextBottom = LblTextBottom.Text --> called to refresh variables for typewriter.
  3. TypeWriterEffectBottom() --> called to perform typewriter effect.
4
  • 4
    Make the delay 0 or just skip the delay? But you will have to add a delay at the end to give the user a chance to read that full text Commented Jun 27, 2020 at 13:53
  • 4
    You can use that IsActive to check whether the typewriter is still active. And then you just need an else, not an else if Commented Jun 27, 2020 at 13:55
  • 1
    For animations use a Timer control. Commented Jun 27, 2020 at 14:13
  • I would look into generators and the yield statement. Unity utilizes them very well for something called coroutines. Very useful for your case. Sadly it was a long time ago I did things like this so I'm not gonna be able to help you with a complete answer. Commented Jul 21, 2020 at 15:45

4 Answers 4

46

Avoid async void. Otherwise you can get an Exception that will break your game and you will not able to catch it.

Then use as less global variables in async methods as possible.

I suggest CancellationTokenSource as thread-safe way to stop the Type Writer.

public async Task TypeWriterEffectBottom(string text, CancellationToken token)
{
    if (this.BackgroundImage != null)
    {
        Debug1.Text = "TypeWriter is active";
        StringBuilder sb = new StringBuilder(text.Length);
        try
        {
            foreach (char c in text)
            {
                LblTextBottom.Text = sb.Append(c).ToString();
                await Task.Delay(30, token);
            }
        }
        catch (OperationCanceledException)
        {
            LblTextBottom.Text = text;
        }
        Debug1.Text = "TypeWriter is finished";
    }
}

Define CTS. It's thread-safe, so it's ok to have it in global scope.

private CancellationTokenSource cts = null;

Call TypeWriter from async method to be able to await it.

// set button layout as "Skip text" here
using (cts = new CancellationTokenSource())
{
    await TypeWriterEffectBottom(yourString, cts.Token);
}
cts = null;
// set button layout as "Go to the next scene" here

And finally

private void PbFastForward_Click(object sender, EventArgs e)
{
    if (cts != null)
    {
        cts?.Cancel();
    }
    else
    {
        // go to the next scene
    }
}   
Sign up to request clarification or add additional context in comments.

3 Comments

SB is much faster than regular string operations. - I am not completely convinced that this is true when you do a ToString after each operation.
Great answer, and kudos to @thim24 for writing this in c# lol. One suggestion to improve this performance-wise; since we are appending to stringbuilder in a loop, reset the internal buffer each iteration before appending the next character. Benchmarks and credit go to this thread: stackoverflow.com/questions/242438/…
@ganjeli there's Java by link. Possible use of sb.Clear() instead of new will not give here any sensitive bonus. Much more bonus can give a custom Control, that supports text appending. Label is the slowest thing here.
19

I pondered on your task a bit more and it occurred to me that it is a good job for the Rx.Net library.

An advantage of this approach is that you have less mutable state to care about and you almost don't need to think about threads, synchronization, etc.; you manipulate higher-level building blocks instead: observables, subscriptions.

I extended the task a bit to better illustrate Rx capabilities:

  • there are two pieces of animated text, each one can be fast-forwarded separately;
  • the user can fast-forward to the final state;
  • the user can reset the animation state.

demo

Here is the form code (C# 8, System.Reactive.Linq v4.4.1):

private enum DialogState
{
    NpcSpeaking,
    PlayerSpeaking,
    EverythingShown
}

private enum EventKind
{
    AnimationFinished,
    Skip,
    SkipToEnd
}

DialogState _state;
private readonly Subject<DialogState> _stateChanges = new Subject<DialogState>();
Dictionary<DialogState, (string, Label)> _lines;
IDisposable _eventsSubscription;
IDisposable _animationSubscription;
public Form1()
{
    InitializeComponent();
    _lines = new Dictionary<DialogState, (string, Label)>
    {
        { DialogState.NpcSpeaking, ("NPC speaking...", lblNpc) },
        { DialogState.PlayerSpeaking, ("Player speaking...", lblCharacter) },
    };
    // tick = 1,2...
    IObservable<long> tick = Observable
        .Interval(TimeSpan.FromSeconds(0.15))
        .ObserveOn(this)
        .StartWith(-1)
        .Select(x => x + 2);
    IObservable<EventPattern<object>> fastForwardClicks = Observable.FromEventPattern(
        h => btnFastForward.Click += h,
        h => btnFastForward.Click -= h);
    IObservable<EventPattern<object>> skipToEndClicks = Observable.FromEventPattern(
        h => btnSkipToEnd.Click += h,
        h => btnSkipToEnd.Click -= h);
    // On each state change animationFarames starts from scratch: 1,2...
    IObservable<long> animationFarames = _stateChanges
        .Select(
            s => Observable.If(() => _lines.ContainsKey(s), tick.TakeUntil(_stateChanges)))
        .Switch();
    var animationFinished = new Subject<int>();
    _animationSubscription = animationFarames.Subscribe(frame =>
    {
        (string line, Label lbl) = _lines[_state];
        if (frame > line.Length)
        {
            animationFinished.OnNext(default);
            return;
        }

        lbl.Text = line.Substring(0, (int)frame);
    });
    IObservable<EventKind> events = Observable.Merge(
        skipToEndClicks.Select(_ => EventKind.SkipToEnd),
        fastForwardClicks.Select(_ => EventKind.Skip),
        animationFinished.Select(_ => EventKind.AnimationFinished));
    _eventsSubscription = events.Subscribe(e =>
    {
        DialogState prev = _state;
        _state = prev switch
        {
            DialogState.NpcSpeaking => WhenSpeaking(e, DialogState.PlayerSpeaking),
            DialogState.PlayerSpeaking => WhenSpeaking(e, DialogState.EverythingShown),
            DialogState.EverythingShown => WhenEverythingShown(e)
        };
        _stateChanges.OnNext(_state);
    });
    Reset();
}

private DialogState WhenEverythingShown(EventKind _)
{
    Close();
    return _state;
}

private DialogState WhenSpeaking(EventKind e, DialogState next)
{
    switch (e)
    {
        case EventKind.AnimationFinished:
        case EventKind.Skip:
        {
            (string l, Label lbl) = _lines[_state];
            lbl.Text = l;
            return next;
        }
        case EventKind.SkipToEnd:
        {
            ShowFinalState();
            return DialogState.EverythingShown;
        }
        default:
            throw new NotSupportedException($"Unknown event '{e}'.");
    }
}

private void ShowFinalState()
{
    foreach ((string l, Label lbl) in _lines.Values)
    {
        lbl.Text = l;
    }
}

private void Reset()
{
    foreach ((_, Label lbl) in _lines.Values)
    {
        lbl.Text = "";
    }
    _state = DialogState.NpcSpeaking;
    _stateChanges.OnNext(_state);
}

protected override void OnClosed(EventArgs e)
{
    _eventsSubscription?.Dispose();
    _animationSubscription?.Dispose();
    base.OnClosed(e);
}

private void btnReset_Click(object sender, EventArgs e)
{
    Reset();
}

2 Comments

Such rare example for Rx.Net based on simple things. Thanks!
For C# 8 it can be done also with brand-new IAsyncEnumerable.
3

I adjusted your code a little bit to achieve your goal. I'm not sure it's the best way to do it, but it should work.

public async void TypeWriterEffectBottom()
{
    if(this.BackgroundImage == null)
    {
        return;
    }
    IsActive = true;
    for(i=0; i < FullTextBottom.Length && IsActive; i++)
    {
        CurrentTextBottom = FullTextBottom.Substring(0, i+1);
        LblTextBottom.Text = CurrentTextBottom;
        await Task.Delay(30);
        Debug1.Text = "IsActive = " + IsActive.ToString();
    }
    IsActive = false;
}

private void PbFastForward_Click(object sender, EventArgs e)
{
    if(IsActive)
    {
        LblTextBottom.Text = FullTextBottom;
        IsActive = false;
        return;
    }
    
    // IsActive == false means all text is printed
    // skip to the next scene
}

UPD: Just noticed that Hans Kesting has suggested pretty much exactly this in his comment.

2 Comments

This code is not synchronized. You may change Label text and then TypeWriter can change it back. It's pretty rare case but it's possible.
@aepot true, I assume TypeWriterEffectBottom starts only once on form initialization.
1

You write what skip / forward button does, so you control it. Just have a check if the length of written text is equal to text that supposed to be written and if yes move as usual if not just display the text in full have delay to be read and move on

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.