Waiting to die (sound and animation)

Godot Version

4.3 rc3

Question

You get shot. You scream. You explode. You get freed. Your children might or might not do the same.

extends Enemy

func on_destroy():
    if %AudioStreamPlayer2D:
        %AudioStreamPlayer2D.play()
    if %AnimatedSprite2D:
        %AnimatedSprite2D.visible = true
        %AnimatedSprite2D.play()
    if %AudioStreamPlayer2D:
        await %AudioStreamPlayer2D.finished
    if %AnimatedSprite2D:
        await %AnimatedSprite2D.animation_finished
    self.queue_free()

This works but i’ve seen esteemed members of this community say this code sucks and should be clubbed to death with a lead pipe.

Is this bad code? Should i be using a state machine or signals instead? If so, what would that look like? In my head it’s something like:

func on_destroy():
    if have_sound: sound_player.finished.connect(on_sound_finished)
    if have_visuals: animation_player.finished.connect(on_visuals_finished)
    if !have_sound and !have_visuals: check_life_support()
func on_sound_finished():
    waiting_on_death_cries = false
    check_life_support()
func on_visuals_finished():
    waiting_on_visuals = false
    check_life_support()
func check_life_support():
    if !waiting_on_sound and !waiting_on_visuals: queue_free()

Is that better? It seems like a lot more code and state variables and i’m unclear on what it buys me. Or did i implement a signal solution badly and there’s a way more elegant approach?

I am no expert, but I recently had a similar situation.

In the end, the use of a composition based state machine, which made maintainability so much easier, but I had to go with a few death states like ‘exploding’ and ‘dead’. In the exploding state my ships are blowing up, with animations, particles and sounds etc. That state then lead to the death state, where the body was cleaned up out of dictionaries and various trackers and then freed. In other actors I used three states, disabled, exploding and dead.

I have learned to keep all my state changes in one place, usually the enemy_manager or the ally_manager or the player_manager. That keeps everything clean. I used signals to indicate if a state was completed, or external events for continuous states like ‘attacking’.

All my states contol other managers, like the life_manager, visuals_manager, score_manager etc. This is how I waited for the explosion node in my visuals_manager to finish the explosion cycle.


func _process(_delta):
	check_if_explosion_completed()
	if explosion_completed:
		SignalManager.emit_signal("explosion_completed")


func _on_animation_finished():
	flame_animation_finished = true


func _on_explosion_particles_finished():
	flame_particles_finished = true
	
	
func _on_explosion_sound_finished():
	flame_sound_finished = true


func check_if_explosion_completed():
	if flame_animation_finished and flame_particles_finished and flame_sound_finished:
		explosion_completed = true

The state_manager picks up the signal and changes state to Dead, where cleanups happen and queue freeing.

I get to re-use the same state_manager in all my actors, and can nearly always re-use different states by dragging the state node into the state manager. (It picks up the children automatically adding them to the dictionary of available states etc)

The eploding state also preps everything for the death cycle when it is entered:



func enter_state():
	set_process(true)
	Host.EngineManager.set_engines_enabled(false)
	Host.WeaponsManager.set_weapons_enabled(false)
	Host.MovementManager.set_speed_to_zero()
	Host.VisualsManager.handle_death_cycle()

Since implementing this, adding and controlling behaviour of new actors (allys or enemies) has become a relatively simple task, but before it was shorter code (as you pointed out) but very quickly became unmaintanable. Now I can track bugs down very easily and quickly.

Is there a better way to do this? I don’t know. Would love to hear of one if there is.

As for the correctness of your code, if it works, then it works. There is no ‘correct’ way. However technical debt is a real thing, and only once you have produced spaghetti code, do you realise how awful it is when an inevitable change, bug fix or complication is introduced.

For me, separation of concerns and single responsibility are the most important aspects of maintainable code, not the length of the code.

Best wishes,

Paul

PS When I am making a prototype I hammer out all the code as fast as possible. But when I start again to make the production version, that is when I implement all the structures and architecture to keep my code as clean as possible. So your code, depending on what you were coding it for, could be perfectly acceptable and in no way needs to be clubbed to death :slight_smile: IMHO.

1 Like