#204 the Beam Collimator widens the beam instead of adding range to one that already crosses the screen; beam width becomes a per-shot vessel stat. #205 the Energy Propulser gains three upgrades - dash recharge, dash reach, and boosted-cruise drain - which turns three constants into vessel stats. #206 three further doctrines carry the fleet from ten to twenty. #207 the prologue's World Ship gets a marker, through the scanner's own guidance arrow rather than a second one. #208 #210 #213 #214 four new synthesised sounds: the breach opening, cargo reaching the hold, a rock breaking, and a gas bomb blooming. #215 building health bars are larger, plated and framed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
130 lines
4.7 KiB
GDScript
130 lines
4.7 KiB
GDScript
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"
|
|
|
|
const SOUNDS: Dictionary = {
|
|
SHOT: "res://audio/sfx_shot.wav",
|
|
SHIP_DEATH: "res://audio/sfx_ship_death.wav",
|
|
ENEMY_DEATH: "res://audio/sfx_ship_death.wav",
|
|
BUILDING_DEATH: "res://audio/sfx_building_death.wav",
|
|
DIALOG: "res://audio/sfx_dialog.wav",
|
|
BOSS_SCREAM: "res://audio/sfx_boss_scream.wav",
|
|
PICKUP: "res://audio/sfx_pickup.wav",
|
|
ASTEROID_BREAK: "res://audio/sfx_asteroid_break.wav",
|
|
GAS_BLAST: "res://audio/sfx_gas_blast.wav",
|
|
BLACK_HOLE: "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,
|
|
}
|
|
|
|
# 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,
|
|
}
|
|
|
|
|
|
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))
|
|
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()
|