Saturday, May 13, 2023

Awaiting multiple signals in Godot GDScript

I ran into a problem with my summertime project where I want to wait for multiple coroutines or signals to complete. These happen to be related to animations. Depending on the situation, multiple different animations may play together, but I don't want to advance until all of them are complete. I have used some other asynchronous programming libraries that have built-in support for this, but alas, Godot has no such abstraction. 

I built this utility class that fills the bill. As with my previous post, I decided to share the code here since the "live" version is locked away in a private repository.

class_name CompositeSignal
extends Node

signal finished

var _remaining : int

func add_signal(sig: Signal):
    _remaining += 1
    await sig
    _remaining -= 1
    if _remaining == 0:
        finished.emit()
    

func add_call(object, method_name:String, argv:Array=[])->void:
    _remaining += 1
    await object.callv(method_name, argv)
    _remaining -= 1
    if _remaining == 0:
        finished.emit()

This was developed using TDD using GUT. Here's the test code. There's not actually a test here to demonstrate that the two add_ techniques work together, but that is exactly how my integration works in practice.

extends GutTest

## The amount of time a timer can wait that is safe for a unit test to run
const _TIMER_DURATION := 0.1

var _executions := 0

func before_each()->void:
    _executions = 0


func test_add_call(p=use_parameters([[1],[3]])):
    var composite = autofree(CompositeSignal.new())
    var number_to_add :int = p[0]
    for i in number_to_add:
        composite.add_call(self, "_run_coroutine")
    await wait_for_signal(composite.finished, 2, "Signal should have returned")
    assert_eq(_executions, number_to_add, \
        "There should have been %d invocations." % number_to_add)


func _run_coroutine()->void:
    await get_tree().create_timer(_TIMER_DURATION).timeout
    _executions+=1


func test_add_signal():
    var generator :SignalGenerator = autofree(SignalGenerator.new(self))
    
    var composite = autofree(CompositeSignal.new())
    composite.add_signal(generator.a_signal)
    generator.run()
    
    await wait_for_signal(composite.finished, 2, "Signal should have fired by now.")
    assert_true(generator.completed, "Expected to wait for generator state to update")
    
    
    
class SignalGenerator:
    signal a_signal
    
    var context
    var completed := false
    
    func _init(context_p):
        context = context_p
    
    func run():
        await context.get_tree().create_timer(_TIMER_DURATION).timeout
        completed = true
        a_signal.emit()

2 comments:

  1. Hello. I'm a game dev (and relatively new to godot). I found this post quite helpful, and I also have a few questions.
    1. In your tests, I notice that you only ever add one signal when testing CompositeSignal. Why is that?
    2. Have you made this utility class public on github? If so, would you be willing to share the link?
    I'm interested in finding utility repos (maybe even creating my own) to speed up new projects.

    Thanks for your time, and for this post!
    Best wishes

    ReplyDelete
    Replies
    1. These are parameterized tests, so they run once for each parameter value. The "use_parameters" sends 1 the first time but 3 the second time, so number_to_add will be 1 on the first run and 3 on the second.

      I have not put this into a utility class. You are welcome to take the code and do with it what you will--consider it CC0 / public domain.

      Thanks for checking it out!

      I'm glad you found it helpful!

      Delete