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 ENEMY_DEATH: String = "enemy_death" const BUILDING_DEATH: String = "building_death" const DIALOG: String = "dialog" const BOSS_SCREAM: String = "boss_scream" # #210, #213, #214, #208. const PICKUP: String = "pickup" const ASTEROID_BREAK: String = "asteroid_break" const GAS_BLAST: String = "gas_blast" const BLACK_HOLE: String = "black_hole" # PRELOADED, not paths. play() used to load() on every call, which is a # cache hit after the first time but a synchronous disk read and decode the # first time - on the frame the sound was asked for. Ten sounds meant ten # occasional late starts, one per sound, which is what "sometimes starting a # bit late" was. Swapping in a different file is still just replacing the # .wav; nothing here knows or cares which it is. const SOUNDS: Dictionary = { SHOT: preload("res://audio/sfx_shot.wav"), SHIP_DEATH: preload("res://audio/sfx_ship_death.wav"), ENEMY_DEATH: preload("res://audio/sfx_ship_death.wav"), BUILDING_DEATH: preload("res://audio/sfx_building_death.wav"), DIALOG: preload("res://audio/sfx_dialog.wav"), BOSS_SCREAM: preload("res://audio/sfx_boss_scream.wav"), PICKUP: preload("res://audio/sfx_pickup.wav"), ASTEROID_BREAK: preload("res://audio/sfx_asteroid_break.wav"), GAS_BLAST: preload("res://audio/sfx_gas_blast.wav"), BLACK_HOLE: preload("res://audio/sfx_black_hole.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: -26.0, SHIP_DEATH: -23.0, ENEMY_DEATH: -23.0, BUILDING_DEATH: -23.0, DIALOG: -19.0, BOSS_SCREAM: -13.0, # Quieter than everything else by a clear margin: it is the only sound # here that can happen several times a second for minutes at a time, and # a pickup blip at shot volume would be the loudest thing in the game. PICKUP: -32.0, ASTEROID_BREAK: -24.0, GAS_BLAST: -21.0, # The prologue's one irreversible moment, and nothing else is playing # under it. BLACK_HOLE: -11.0, } # Forgejo #229: how long a sound blocks another of its own kind. # # Every hull fires its own gun and asks for its own shot sound, so a fleet # firing a volley started one voice per ship - the same sample, in the same # frame, at the same level. Those amplitudes ADD, so the volley got louder # for every ship added, and a full fleet was the loudest thing in the game # by a wide margin. # # A volley is one event to the ear, so it gets one voice. The window is # shorter than any weapon's cooldown, so consecutive volleys are still heard # individually - it collapses "six guns at once", never "six shots in a row". # # Deaths and the boss scream are absent deliberately: several things dying # together IS several events, and each is rare enough not to stack. const COALESCE_MSEC: Dictionary = { SHOT: 70, # Ore comes in bursts when a rock breaks, and a burst is one pickup to # the ear. Quieter than a shot but far more frequent (see LEVELS). PICKUP: 60, # Rocks break in twos and threes - that is in the pitch spread comment # below, and it is the same stacking problem. ASTEROID_BREAK: 60, } # When each sound last started, keyed by id. Static because play() is, and # the pool outlives every scene that uses it. static var _last_started_msec: Dictionary = {} # 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 # Pickups repeat harder than shots do, so they vary too - but less, because a # pickup is a short bright tone and a wide spread on one reads as wrong notes # rather than as variety. Rocks also break in twos and threes. const PITCH_SPREAD: Dictionary = { SHOT: SHOT_PITCH_SPREAD, PICKUP: 0.07, ASTEROID_BREAK: 0.09, } # Builds the voice pool before anything needs it. _pool() is lazy, so without # this the first sound in a session paid for twelve AudioStreamPlayer nodes # being created on the frame it was asked for - the worst late start of all, # and the one that happens every session. static func warm(tree: SceneTree) -> void: if tree == null: return # Deferred, like Music.ensure(): this is called from _ready(), where the # parent is still setting up children and a direct add_child() is # refused outright. The lazy path in play() runs during an ordinary # frame and does not need it. var build: Callable = func() -> void: _pool(tree) build.call_deferred() 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 = SOUNDS[id] as AudioStream if stream == null: return var window: int = int(COALESCE_MSEC.get(id, 0)) if window > 0: var now: int = Time.get_ticks_msec() var last: int = int(_last_started_msec.get(id, -1000000)) if now - last < window: return _last_started_msec[id] = now 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)) var spread: float = float(PITCH_SPREAD.get(id, 0.0)) voice.pitch_scale = 1.0 if spread <= 0.0 else 1.0 + randf_range(-spread, spread) voice.play()