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

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

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

Leave a Comment

Hata!: SQLSTATE[HY000] [1045] Access denied for user 'divattrend_liink'@'localhost' (using password: YES)