spaceship/tests/test_sfx.gd
2026-08-31 18:15:37 +02:00

185 lines
6.6 KiB
GDScript

extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_sfx.gd
# Forgejo #146: shots and destruction should be heard. Enemy destruction and
# incoming narration are covered too, so visual events cannot silently miss
# the shared sound pool.
#
# The interesting failures here are not "no sound came out" - they are the
# ones a play-through would not reveal for weeks. A voice pool that runs dry
# under a firing fleet and silently drops every shot after the twelfth. A
# sound started by a node that is being freed on the same frame, which is
# every death in the game. So those are what is checked.
#
# Covers:
# A. every sound the catalog names actually exists and loads
# B. firing plays a shot, and it survives a fleet firing together - no
# voice is lost, none is left stuck
# C. a dying ship is heard, from a node that is freed on the same frame
# D. enemy and structure destruction are heard
# E. levels are in proportion: a shot fires constantly and a death happens
# once, so a shot must be the quieter of the two
# F. sound keeps working while a dialog has the game stopped (#128)
var game_state: Node
var scene: Node
var fleet: Node2D
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.reset_new_game()
game_state.intro_active = false
game_state.reset_quests()
game_state.rocks = 500
func _fail(message: String) -> bool:
print("TEST_SFX: FAIL (%s)" % message)
quit(1)
return true
func _pool() -> Node:
return root.get_node_or_null(Sfx.NODE_NAME)
func _sounding() -> int:
var pool: Node = _pool()
if pool == null:
return 0
var n: int = 0
for voice in pool.get_children():
if (voice as AudioStreamPlayer).playing:
n += 1
return n
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- A. the catalog is real -----------------------------
for id in Sfx.SOUNDS:
var path: String = String(Sfx.SOUNDS[id])
var stream: AudioStream = load(path)
if stream == null:
return _fail("the \"%s\" sound (%s) does not load" % [id, path])
if stream.get_length() <= 0.02:
return _fail("the \"%s\" sound is %.3fs long - that is silence" \
% [id, stream.get_length()])
if not Sfx.LEVELS.has(id):
return _fail("the \"%s\" sound has no level set" % id)
print("%d sounds, all loading, all with a level" % Sfx.SOUNDS.size())
# --- E. and in proportion -------------------------------
if float(Sfx.LEVELS[Sfx.SHOT]) >= float(Sfx.LEVELS[Sfx.SHIP_DEATH]):
return _fail("a shot is as loud as a death - one happens constantly, the other once")
print("shot at %.0fdB, ship death at %.0fdB, structure at %.0fdB" \
% [Sfx.LEVELS[Sfx.SHOT], Sfx.LEVELS[Sfx.SHIP_DEATH],
Sfx.LEVELS[Sfx.BUILDING_DEATH]])
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
fleet = scene.get_node("Fleet")
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.4:
return false
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
if _pool() != null and _sounding() > 0:
return _fail("something was already sounding before anything happened")
# --- B. a shot, and then far more than there are voices --
Sfx.play(self, Sfx.SHOT)
if _sounding() != 1:
return _fail("firing played %d sounds, expected 1" % _sounding())
if _pool().get_parent() != root:
return _fail("the sound pool hangs off the scene - a death would take it with it")
for i in range(Sfx.VOICES * 3):
Sfx.play(self, Sfx.SHOT)
var busy: int = _sounding()
if busy != Sfx.VOICES:
return _fail("%d voices sounding after %d shots, the pool holds %d" \
% [busy, Sfx.VOICES * 3, Sfx.VOICES])
if _pool().get_child_count() != Sfx.VOICES:
return _fail("the pool grew to %d voices" % _pool().get_child_count())
print("%d shots into a %d-voice pool: %d sounding, none leaked" \
% [Sfx.VOICES * 3, Sfx.VOICES, busy])
# --- F. and a dialog does not silence it -----------------
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var narration: Control = scene.get_node("UI/NarrationBanner")
narration.say("Everything stops but this.", "TEST")
if _sounding() == 0:
return _fail("a narration line appeared in silence")
if not paused:
return _fail("the dialog did not stop the game, so this proves nothing")
for voice in _pool().get_children():
if not (voice as AudioStreamPlayer).can_process():
return _fail("sound stops whenever a dialog is up")
print("a dialog stops the game and sound carries on")
while narration.dismiss():
pass
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
# --- C. a death, from a node freed on the same frame -----
fleet.spawn_ships(2, Vector2.ZERO,
PackedStringArray([GameState.VESSEL_INTERCEPTOR,
GameState.VESSEL_INTERCEPTOR]))
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var doomed: Node2D = fleet.ships[0]
doomed.take_damage(1)
if not is_instance_valid(doomed) or not doomed.is_queued_for_deletion():
return _fail("the ship was not freed, so this is not the case that matters")
if _sounding() == 0:
return _fail("a vessel died in silence")
print("a vessel freed on the frame it dies is still heard")
# An enemy uses the same persistent pool. Its own node is freed on
# this frame too, so the call must happen before queue_free().
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate()
scene.add_child(enemy)
enemy.take_damage(enemy.hp)
if not enemy.is_queued_for_deletion():
return _fail("the enemy was not destroyed")
if _sounding() == 0:
return _fail("an enemy vessel exploded in silence")
print("an enemy vessel explosion is heard")
# --- D. a structure ------------------------------------
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var field: Node2D = scene.get_node("BuildingField")
var id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER,
Vector2(400.0, 0.0))
field.rebuild_all()
game_state.destroy_building(id)
if _sounding() == 0:
return _fail("a structure came down in silence")
print("a structure coming down is heard")
print("TEST_SFX: PASS")
quit(0)
return true
return false