C# Manually stopping an asynchronous for-statement (typewriter effect)

Solution 1:

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
    }
}   

Solution 2:

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();
}

Solution 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.

Solution 4:

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