diff --git a/audio/Sfx.gd b/audio/Sfx.gd new file mode 100644 index 0000000..83b614d --- /dev/null +++ b/audio/Sfx.gd @@ -0,0 +1,96 @@ +class_name Sfx +extends RefCounted + +# Forgejo #146: the three things that happen to something and should be +# heard - a shot leaving a gun, a vessel dying, a structure coming down. +# +# The sounds themselves are synthesised (see audio/make_sfx.py) rather than +# downloaded. The ticket asked for free sources and the good ones are in its +# comments, but a generated sound has no licence to track, no attribution to +# keep, and no binary in the repository whose provenance nobody can check +# later. Swapping in a file from one of those packs is a matter of replacing +# the .wav - nothing here knows or cares which it is. +# +# Parented to the tree root for the same reason Music is: a sound started by +# something that is about to be freed - which is every one of these, a dying +# ship being the obvious case - cannot live on the thing that started it. +# +# A POOL rather than one player, because these overlap constantly: a fleet +# firing is several shots inside one shot's length. The oldest is recycled +# when they are all busy, which is the right thing to lose. + +const NODE_NAME: String = "SfxPlayers" +const VOICES: int = 12 + +const SHOT: String = "shot" +const SHIP_DEATH: String = "ship_death" +const BUILDING_DEATH: String = "building_death" + +const SOUNDS: Dictionary = { + SHOT: "res://audio/sfx_shot.wav", + SHIP_DEATH: "res://audio/sfx_ship_death.wav", + BUILDING_DEATH: "res://audio/sfx_building_death.wav", +} + +# Per-sound level. A shot is fired constantly and a death happens once, so +# they cannot share a volume and stay in proportion. +const LEVELS: Dictionary = { + SHOT: -22.0, + SHIP_DEATH: -6.0, + BUILDING_DEATH: -4.0, +} + +# Shots vary a little in pitch so a stream of them does not turn into one +# flat machine-gun note. The deaths do not: they are events, and a wobbling +# explosion sounds broken rather than varied. +const SHOT_PITCH_SPREAD: float = 0.12 + + +static func _pool(tree: SceneTree) -> Node: + var existing: Node = tree.root.get_node_or_null(NODE_NAME) + if existing != null: + return existing + var pool := Node.new() + pool.name = NODE_NAME + # #128 stops the tree for dialogs. A sound already in flight should + # finish rather than freeze mid-bang. + pool.process_mode = Node.PROCESS_MODE_ALWAYS + tree.root.add_child(pool) + for i in range(VOICES): + var voice := AudioStreamPlayer.new() + voice.process_mode = Node.PROCESS_MODE_ALWAYS + pool.add_child(voice) + return pool + + +# Plays one sound. Safe from a node on its way out of the tree, and safe to +# call far more often than there are voices. +static func play(tree: SceneTree, id: String) -> void: + if tree == null or not SOUNDS.has(id): + return + var stream: AudioStream = load(String(SOUNDS[id])) + if stream == null: + return + var pool: Node = _pool(tree) + var voice: AudioStreamPlayer = null + for child in pool.get_children(): + if not (child as AudioStreamPlayer).playing: + voice = child + break + if voice == null: + # All busy. The oldest is the one furthest through its own sound, so + # it is the one with the least left to lose. + var most: float = -1.0 + for child in pool.get_children(): + var at: float = (child as AudioStreamPlayer).get_playback_position() + if at > most: + most = at + voice = child + if voice == null: + return + voice.stream = stream + voice.volume_db = float(LEVELS.get(id, -10.0)) + voice.pitch_scale = 1.0 + if id == SHOT: + voice.pitch_scale = 1.0 + randf_range(-SHOT_PITCH_SPREAD, SHOT_PITCH_SPREAD) + voice.play() diff --git a/audio/Sfx.gd.uid b/audio/Sfx.gd.uid new file mode 100644 index 0000000..9bae373 --- /dev/null +++ b/audio/Sfx.gd.uid @@ -0,0 +1 @@ +uid://nhg0c87ie053 diff --git a/audio/make_sfx.py b/audio/make_sfx.py new file mode 100644 index 0000000..635c671 --- /dev/null +++ b/audio/make_sfx.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Generates the game's sound effects, from scratch. + +Forgejo #146 asked for free SFX sources, and the ones worth using are listed +in the ticket - Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound's CC0 +filter. What ships here is synthesised instead, for one reason: a generated +sound has no licence, no attribution to keep track of, and no binary in the +repository whose provenance nobody can check a year from now. + +It also means the sounds are DATA rather than assets. If a shot is too sharp +or an explosion too short, the fix is a number in this file and a re-run, +not a hunt through a sound library. + +Run: python3 audio/make_sfx.py +""" +import math +import random +import struct +import wave + +RATE = 44100 + + +def _write(name, samples): + peak = max(1e-9, max(abs(s) for s in samples)) + frames = b"".join(struct.pack(" 0 else -1.0) + out.append(tone * _env(i, n, 0.02, 2.2)) + return out + + +def explosion(seconds, low_hz, brightness, rumble_hz): + """Noise through a one-pole low-pass, plus a falling sine underneath it. + The sine is what gives it a size: without it a burst of noise is a hiss, + however loud.""" + n = int(RATE * seconds) + out = [] + last = 0.0 + phase = 0.0 + rng = random.Random(7) + for i in range(n): + t = i / n + # The filter opens for an instant and closes as it decays, which is + # what a real blast does - the crack first, the roar after. + cutoff = brightness * (1.0 - t) ** 1.6 + 0.02 + last += cutoff * (rng.uniform(-1.0, 1.0) - last) + hz = low_hz * (rumble_hz / low_hz) ** t + phase += 2.0 * math.pi * hz / RATE + body = 0.55 * math.sin(phase) + out.append((last * 1.4 + body) * _env(i, n, 0.004, 1.7)) + return out + + +if __name__ == "__main__": + _write("sfx_shot", shot()) + # A vessel: sharp, bright, over quickly. + _write("sfx_ship_death", explosion(0.85, 220.0, 0.55, 45.0)) + # A structure: slower, deeper, and it keeps falling down afterwards. + _write("sfx_building_death", explosion(1.6, 130.0, 0.34, 26.0)) diff --git a/audio/sfx_building_death.wav b/audio/sfx_building_death.wav new file mode 100644 index 0000000..8a76045 Binary files /dev/null and b/audio/sfx_building_death.wav differ diff --git a/audio/sfx_building_death.wav.import b/audio/sfx_building_death.wav.import new file mode 100644 index 0000000..f16a2f8 --- /dev/null +++ b/audio/sfx_building_death.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cucu8do3xu62b" +path="res://.godot/imported/sfx_building_death.wav-5a0672915182205beec566265df53340.sample" + +[deps] + +source_file="res://audio/sfx_building_death.wav" +dest_files=["res://.godot/imported/sfx_building_death.wav-5a0672915182205beec566265df53340.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx_ship_death.wav b/audio/sfx_ship_death.wav new file mode 100644 index 0000000..c99e280 Binary files /dev/null and b/audio/sfx_ship_death.wav differ diff --git a/audio/sfx_ship_death.wav.import b/audio/sfx_ship_death.wav.import new file mode 100644 index 0000000..f7f6c63 --- /dev/null +++ b/audio/sfx_ship_death.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cr6bt4w8n6d24" +path="res://.godot/imported/sfx_ship_death.wav-0969b7a92e0e9fb802408222617228e0.sample" + +[deps] + +source_file="res://audio/sfx_ship_death.wav" +dest_files=["res://.godot/imported/sfx_ship_death.wav-0969b7a92e0e9fb802408222617228e0.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx_shot.wav b/audio/sfx_shot.wav new file mode 100644 index 0000000..5c479ad Binary files /dev/null and b/audio/sfx_shot.wav differ diff --git a/audio/sfx_shot.wav.import b/audio/sfx_shot.wav.import new file mode 100644 index 0000000..6330b5e --- /dev/null +++ b/audio/sfx_shot.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://c0le6ax67g411" +path="res://.godot/imported/sfx_shot.wav-cdb41c709bd7357ff93382562f01a47e.sample" + +[deps] + +source_file="res://audio/sfx_shot.wav" +dest_files=["res://.godot/imported/sfx_shot.wav-cdb41c709bd7357ff93382562f01a47e.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/buildings/BuildingField.gd b/buildings/BuildingField.gd index d950509..e85844f 100644 --- a/buildings/BuildingField.gd +++ b/buildings/BuildingField.gd @@ -37,6 +37,7 @@ func _on_building_destroyed(building_id: int, _type: String) -> void: continue if int(b.building_id) == building_id: BuildingExplosionVFX.spawn(get_tree().root, b.global_position) + Sfx.play(get_tree(), Sfx.BUILDING_DEATH) b.queue_free() continue kept.append(b) diff --git a/tests/test_sfx.gd b/tests/test_sfx.gd new file mode 100644 index 0000000..f80487b --- /dev/null +++ b/tests/test_sfx.gd @@ -0,0 +1,167 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_sfx.gd +# Forgejo #146: a shot, a vessel dying and a structure coming down should be +# heard. +# +# 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. a destroyed structure is 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. 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 ----------------- + var narration: Control = scene.get_node("UI/NarrationBanner") + narration.say("Everything stops but this.", "TEST") + 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") + + # --- 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 diff --git a/tests/test_sfx.gd.uid b/tests/test_sfx.gd.uid new file mode 100644 index 0000000..a642d95 --- /dev/null +++ b/tests/test_sfx.gd.uid @@ -0,0 +1 @@ +uid://cru3clxyxahgn diff --git a/vessels/Ship.gd b/vessels/Ship.gd index d98619b..b2fb789 100644 --- a/vessels/Ship.gd +++ b/vessels/Ship.gd @@ -175,6 +175,10 @@ func take_damage(_amount: int) -> bool: # still freed on this frame, so nothing about who is in the fleet, who # can be shot, or who is a target changes. ShipWreckVFX.spawn(get_tree().root, self, _breakaway_direction()) + # #146: the bang goes with the wreck being thrown clear, not with the + # explosion at the end of its drift - the hit is what the player needs + # to hear, and hearing it late reads as a different event. + Sfx.play(get_tree(), Sfx.SHIP_DEATH) destroyed.emit() queue_free() return false diff --git a/vessels/VesselWeaponComponent.gd b/vessels/VesselWeaponComponent.gd index f52d064..2e2c89b 100644 --- a/vessels/VesselWeaponComponent.gd +++ b/vessels/VesselWeaponComponent.gd @@ -109,4 +109,7 @@ func tick(delta: float) -> void: # #87: sparks off the hull that fired, so a shot is visibly attached to # the vessel it came from. MUZZLE_SPARK.spawn(ship.get_tree().root, ship.global_position, heading) + # #146: and heard. Beside the spark rather than inside it, because the + # spark is about this hull and the sound is about the shot. + Sfx.play(ship.get_tree(), Sfx.SHOT) fire_timer = fire_cooldown