extends Node ## Sound effects. Recorded ones are Kenney's CC0 packs (assets/audio/sfx/kenney, ## see assets/audio/CREDITS.md); the rest are synthesised by ## tools/audio/make_audio.py (assets/audio/sfx/synth). Each sound has a few ## variants and a little pitch spread, so repeats don't sound mechanical. ## ## - `at(id, pos)`: something happened in the world. In single-player it plays ## there. On a server (which has no speakers) it's sent to every player ## within earshot, who hear it from that spot: you hear a friend chopping. ## - `for_player(player, id)`: a sound only that player hears, close up ## (eating, getting hurt). On a server, sent to that player's game. ## - `ui(id)`: menus and the HUD, only ever on this machine. ## Headless (tests, a dedicated server) nothing is played, but every sound ## asked for is logged in `heard`. ## ## Volumes: buses Music, SFX and Ambience under Master, set in the pause menu ## and kept in user://audio.json. const K := "res://assets/audio/sfx/kenney/" const S := "res://assets/audio/sfx/synth/" const C := "res://assets/audio/sfx/creatures/" const EARSHOT := 45.0 ## a server sends a sound to players this close ## id -> files (or a [prefix, count] pair), volume (dB), pitch range, reach (m), ## and an optional second sound layered on top. const SOUNDS := { "chop": {"set": [K + "chop_%d.ogg", 5], "db": -2.0, "pitch": [0.9, 1.1]}, "tree_fall": {"set": [K + "wood_heavy_%d.ogg", 5], "db": 0.0, "pitch": [0.6, 0.7], "reach": 60.0, "with": "creak"}, "creak": {"set": [K + "creak_%d.ogg", 3], "db": -2.0, "pitch": [0.8, 0.95], "reach": 50.0}, "mine": {"set": [K + "mine_%d.ogg", 5], "db": -1.0, "pitch": [0.9, 1.1]}, "rock_break": {"set": [K + "mine_%d.ogg", 5], "db": 1.0, "pitch": [0.6, 0.7], "reach": 50.0}, "rustle": {"set": [K + "cloth_%d.ogg", 4], "db": -4.0, "pitch": [0.7, 0.9]}, "pickup": {"set": [K + "leather_%d.ogg", 3], "db": -6.0, "pitch": [0.95, 1.15], "reach": 15.0}, "craft": {"set": [K + "metal_click_%d.ogg", 2], "db": -6.0, "pitch": [0.9, 1.1], "reach": 15.0, "with": "pickup"}, "place_wood": {"set": [K + "plank_%d.ogg", 5], "db": -1.0, "pitch": [0.85, 1.05]}, "place_stone": {"set": [K + "mine_%d.ogg", 5], "db": -3.0, "pitch": [1.1, 1.3]}, "place_soft": {"set": [K + "cloth_%d.ogg", 4], "db": -3.0, "pitch": [0.8, 1.0], "reach": 15.0}, "dismantle": {"set": [K + "wood_heavy_%d.ogg", 5], "db": -2.0, "pitch": [1.0, 1.15]}, "door_open": {"set": [K + "door_open_%d.ogg", 2], "db": -3.0, "pitch": [0.95, 1.05]}, "door_close": {"set": [K + "door_close_%d.ogg", 4], "db": -3.0, "pitch": [0.95, 1.05]}, "swing": {"set": [S + "swing_%d.ogg", 3], "db": -9.0, "pitch": [0.9, 1.15], "reach": 12.0}, "hit_flesh": {"set": [K + "punch_%d.ogg", 5], "db": -2.0, "pitch": [0.85, 1.05]}, "hit_bone": {"set": [K + "wood_light_%d.ogg", 5], "db": -1.0, "pitch": [1.2, 1.4]}, "creature_die": {"set": [K + "soft_heavy_%d.ogg", 5], "db": 0.0, "pitch": [0.7, 0.85]}, "butcher": {"set": [K + "knife_%d.ogg", 2], "db": -3.0, "pitch": [0.9, 1.1], "reach": 15.0}, "eat": {"set": [S + "eat_%d.ogg", 3], "db": -5.0, "pitch": [0.9, 1.15], "reach": 8.0}, "hurt": {"set": [K + "punch_heavy_%d.ogg", 5], "db": -2.0, "pitch": [0.8, 0.95], "with": "thump"}, "thump": {"set": [S + "thump_%d.ogg", 2], "db": -5.0, "pitch": [0.9, 1.1]}, "death": {"files": [S + "death.ogg"], "db": -2.0, "pitch": [1.0, 1.0]}, "cast": {"files": [S + "cast.ogg"], "db": -6.0, "pitch": [0.95, 1.1], "reach": 20.0}, "splash": {"set": [S + "splash_%d.ogg", 2], "db": -4.0, "pitch": [0.9, 1.15], "reach": 30.0}, "swim": {"set": [S + "splash_%d.ogg", 2], "db": -14.0, "pitch": [1.2, 1.5], "reach": 15.0}, "bite": {"files": [S + "plop.ogg"], "db": -3.0, "pitch": [0.9, 1.1], "reach": 25.0}, "reel": {"files": [S + "reel.ogg"], "db": -7.0, "pitch": [0.95, 1.05], "reach": 15.0}, "catch": {"files": [S + "splash_big.ogg"], "db": -3.0, "pitch": [0.95, 1.05], "reach": 30.0}, "bow": {"files": [S + "bow.ogg"], "db": -4.0, "pitch": [0.92, 1.08], "reach": 25.0}, "throw": {"set": [S + "swing_%d.ogg", 3], "db": -5.0, "pitch": [0.7, 0.8], "reach": 20.0}, "fuel": {"set": [K + "wood_light_%d.ogg", 5], "db": -4.0, "pitch": [0.8, 1.0], "reach": 15.0}, "wear": {"set": [K + "cloth_%d.ogg", 4], "db": -4.0, "pitch": [0.9, 1.1], "reach": 10.0}, "step_grass": {"set": [K + "step_grass_%d.ogg", 5], "db": -15.0, "pitch": [0.9, 1.1], "reach": 12.0}, "step_snow": {"set": [K + "step_snow_%d.ogg", 5], "db": -13.0, "pitch": [0.9, 1.1], "reach": 12.0}, "step_wood": {"set": [K + "step_wood_%d.ogg", 5], "db": -12.0, "pitch": [0.9, 1.1], "reach": 12.0}, "step_stone": {"set": [K + "step_stone_%d.ogg", 5], "db": -13.0, "pitch": [0.9, 1.1], "reach": 12.0}, "ui_open": {"files": [K + "ui_open_0.ogg"], "db": -10.0, "pitch": [1.0, 1.0]}, "ui_close": {"files": [K + "ui_close_0.ogg"], "db": -10.0, "pitch": [1.0, 1.0]}, "ui_tick": {"files": [K + "ui_tick_0.ogg"], "db": -14.0, "pitch": [0.95, 1.05]}, "ui_good": {"files": [K + "ui_good_0.ogg"], "db": -8.0, "pitch": [1.0, 1.0]}, "ui_step": {"files": [K + "ui_step_0.ogg"], "db": -8.0, "pitch": [1.0, 1.0]}, "ui_catch": {"files": [K + "ui_catch_0.ogg"], "db": -8.0, "pitch": [1.0, 1.0]}, "ui_tip": {"files": [K + "ui_tip_0.ogg"], "db": -12.0, "pitch": [1.0, 1.0]}, # Creature voices (creature.gd's VOICES picks them). "wolf_growl": {"set": [C + "wolf_growl_%d.ogg", 3], "db": -3.0, "pitch": [0.92, 1.08], "reach": 30.0}, "wolf_snarl": {"set": [C + "wolf_snarl_%d.ogg", 2], "db": -1.0, "pitch": [0.92, 1.1], "reach": 25.0}, "wolf_yelp": {"set": [C + "wolf_yelp_%d.ogg", 2], "db": -3.0, "pitch": [0.92, 1.1], "reach": 30.0}, "alpha_growl": {"set": [C + "wolf_growl_%d.ogg", 3], "db": 0.0, "pitch": [0.68, 0.76], "reach": 40.0}, "alpha_snarl": {"set": [C + "wolf_snarl_%d.ogg", 2], "db": 1.0, "pitch": [0.7, 0.78], "reach": 30.0}, "alpha_yelp": {"set": [C + "wolf_yelp_%d.ogg", 2], "db": -1.0, "pitch": [0.72, 0.8], "reach": 35.0}, "spirit_growl": {"set": [C + "wolf_growl_%d.ogg", 3], "db": -7.0, "pitch": [1.15, 1.3], "reach": 25.0}, "spirit_snarl": {"set": [C + "wolf_snarl_%d.ogg", 2], "db": -5.0, "pitch": [1.15, 1.3], "reach": 20.0}, "bear_roar": {"set": [C + "bear_roar_%d.ogg", 2], "db": 2.0, "pitch": [0.92, 1.05], "reach": 70.0}, "bear_grunt": {"set": [C + "bear_grunt_%d.ogg", 2], "db": 0.0, "pitch": [0.85, 1.05], "reach": 30.0}, "bear_hurt": {"set": [C + "bear_grunt_%d.ogg", 2], "db": 1.0, "pitch": [1.2, 1.35], "reach": 35.0}, "boar_grunt": {"set": [C + "boar_grunt_%d.ogg", 2], "db": -3.0, "pitch": [0.9, 1.1], "reach": 22.0}, "boar_squeal": {"files": [C + "boar_squeal_0.ogg"], "db": -2.0, "pitch": [0.9, 1.1], "reach": 35.0}, "stag_bellow": {"set": [C + "stag_bellow_%d.ogg", 2], "db": 3.0, "pitch": [0.92, 1.05], "reach": 90.0}, "stag_snort": {"files": [C + "stag_snort_0.ogg"], "db": -1.0, "pitch": [0.85, 1.0], "reach": 30.0}, "guard_rattle": {"set": [C + "guard_rattle_%d.ogg", 2], "db": -2.0, "pitch": [0.85, 1.1], "reach": 25.0}, "crow_caw": {"set": [C + "crow_caw_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.1], "reach": 40.0}, "fox_yip": {"set": [C + "fox_yip_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.12], "reach": 30.0}, "gull_cry": {"set": [C + "gull_cry_%d.ogg", 2], "db": -5.0, "pitch": [0.9, 1.12], "reach": 50.0}, "crab_click": {"set": [C + "crab_click_%d.ogg", 2], "db": -6.0, "pitch": [0.85, 1.15], "reach": 14.0}, } const BUSES := ["Music", "SFX", "Ambience", "Voice"] const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7, "Voice": 1.0} const POOL_3D := 20 const POOL_2D := 6 var prefs_path := "user://audio.json" var volumes: Dictionary = DEFAULT_VOLUMES.duplicate() var heard: Array = [] ## [id, position or null] of each sound asked for (newest last) var ids: Array = [] ## SOUNDS' keys, sorted: a sound's number on the network var _streams: Dictionary = {} ## id -> [AudioStream] var _p3: Array = [] var _p2: Array = [] var _i3 := 0 var _i2 := 0 var _rng := RandomNumberGenerator.new() var _quiet := false ## headless: log, don't play func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS ids = SOUNDS.keys() ids.sort() _rng.randomize() _quiet = DisplayServer.get_name() == "headless" for b in BUSES: if AudioServer.get_bus_index(b) == -1: AudioServer.add_bus() var i := AudioServer.bus_count - 1 AudioServer.set_bus_name(i, b) AudioServer.set_bus_send(i, "Master") load_prefs() if _quiet: return for i in range(POOL_3D): var p := AudioStreamPlayer3D.new() p.bus = "SFX" p.attenuation_model = AudioStreamPlayer3D.ATTENUATION_INVERSE_DISTANCE p.unit_size = 4.0 add_child(p) _p3.append(p) for i in range(POOL_2D): var p := AudioStreamPlayer.new() p.bus = "SFX" add_child(p) _p2.append(p) # --- volumes --------------------------------------------------------------------- func load_prefs() -> void: volumes = DEFAULT_VOLUMES.duplicate() if FileAccess.file_exists(prefs_path): var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(prefs_path)) if d is Dictionary: for k in volumes: if d.has(k): volumes[k] = clampf(float(d[k]), 0.0, 1.0) for k in volumes: _apply_volume(k) func set_volume(bus: String, v: float) -> void: volumes[bus] = clampf(v, 0.0, 1.0) _apply_volume(bus) var f := FileAccess.open(prefs_path, FileAccess.WRITE) if f != null: f.store_string(JSON.stringify(volumes, "\t")) func _apply_volume(bus: String) -> void: var i := AudioServer.get_bus_index(bus) if i == -1: return var v: float = volumes[bus] AudioServer.set_bus_mute(i, v <= 0.001) AudioServer.set_bus_volume_db(i, linear_to_db(maxf(v, 0.001))) # --- playing ----------------------------------------------------------------------- ## Something happened at `pos` in the world. func at(id: String, pos: Vector3) -> void: if not SOUNDS.has(id): push_warning("[sfx] no sound '%s'" % id) return _log(id, pos) if Net.is_server(): var n := ids.find(id) for peer in Net.players: var pp: Vector3 = Net.player_pos(int(peer)) if pp != Vector3.INF and pp.distance_to(pos) <= float(SOUNDS[id].get("reach", 30.0)) + 5.0: _s2c_at.rpc_id(int(peer), n, pos) return _play3(id, pos) ## Only `player` hears it, close up (their own eating, getting hurt). func for_player(player: Node, id: String) -> void: if not SOUNDS.has(id): return if Net.is_server(): _log(id, null) if player != null and player.get("peer") != null: _s2c_me.rpc_id(int(player.peer), ids.find(id)) return me(id) ## A sound for this machine's own player (not sent anywhere). func me(id: String) -> void: _log(id, null) _play2(id) ## Menus and the HUD. func ui(id: String) -> void: _log(id, null) _play2(id) @rpc("authority", "call_remote", "unreliable_ordered") func _s2c_at(n: int, pos: Vector3) -> void: if n >= 0 and n < ids.size(): _log(ids[n], pos) _play3(ids[n], pos) @rpc("authority", "call_remote", "unreliable_ordered") func _s2c_me(n: int) -> void: if n >= 0 and n < ids.size(): _log(ids[n], null) _play2(ids[n]) func _log(id: String, pos: Variant) -> void: heard.append([id, pos]) if heard.size() > 400: heard = heard.slice(200) ## How often `id` was asked for since the log was last cleared (tests). func count(id: String) -> int: var n := 0 for h in heard: if h[0] == id: n += 1 return n func _play3(id: String, pos: Vector3) -> void: if _quiet or _p3.is_empty(): return var d: Dictionary = SOUNDS[id] var st := _pick(id) if st == null: return var p: AudioStreamPlayer3D = _p3[_i3] _i3 = (_i3 + 1) % _p3.size() p.stop() p.stream = st p.volume_db = float(d.get("db", 0.0)) p.max_distance = float(d.get("reach", 30.0)) p.pitch_scale = _rng.randf_range(d.pitch[0], d.pitch[1]) p.global_position = pos p.play() if d.has("with"): _play3(String(d["with"]), pos) func _play2(id: String) -> void: if _quiet or _p2.is_empty(): return var d: Dictionary = SOUNDS[id] var st := _pick(id) if st == null: return var p: AudioStreamPlayer = _p2[_i2] _i2 = (_i2 + 1) % _p2.size() p.stop() p.stream = st p.volume_db = float(d.get("db", 0.0)) p.pitch_scale = _rng.randf_range(d.pitch[0], d.pitch[1]) p.play() if d.has("with"): _play2(String(d["with"])) func _pick(id: String) -> AudioStream: if not _streams.has(id): var got: Array = [] for path in files(id): var s: Variant = load(path) if s is AudioStream: got.append(s) _streams[id] = got var l: Array = _streams[id] return l[_rng.randi() % l.size()] if not l.is_empty() else null ## A sound's files. static func files(id: String) -> Array: var d: Dictionary = SOUNDS[id] if d.has("files"): return d.files var out: Array = [] for i in range(int(d.set[1])): out.append(String(d.set[0]) % i) return out