From 9e13c8bbe3417e63749e8b9b5507bb1b547a3ad0 Mon Sep 17 00:00:00 2001 From: Kirill Date: Sat, 3 Oct 2026 13:21:53 +0300 Subject: [PATCH] Implement audio management system with effects and music players; add settings for sound and music volume --- autoload/global.gd | 9 ++++ data/owniverse_audio_loader.gd | 40 ++++++++++++++ data/owniverse_audio_loader.gd.uid | 1 + stage/stage.gd | 3 ++ tests/test_effects_player.gd | 47 ++++++++++++++++ tests/test_effects_player.gd.uid | 1 + tests/test_music_player.gd | 54 +++++++++++++++++++ tests/test_music_player.gd.uid | 1 + ui/audio/owniverse_effects_player.gd | 31 +++++++++++ ui/audio/owniverse_effects_player.gd.uid | 1 + ui/audio/owniverse_music_player.gd | 69 ++++++++++++++++++++++++ ui/audio/owniverse_music_player.gd.uid | 1 + ui/main_menu/main_menu.gd | 66 +++++++++++++++++++++++ world/world.tscn | 10 ++++ 14 files changed, 334 insertions(+) create mode 100644 data/owniverse_audio_loader.gd create mode 100644 data/owniverse_audio_loader.gd.uid create mode 100644 tests/test_effects_player.gd create mode 100644 tests/test_effects_player.gd.uid create mode 100644 tests/test_music_player.gd create mode 100644 tests/test_music_player.gd.uid create mode 100644 ui/audio/owniverse_effects_player.gd create mode 100644 ui/audio/owniverse_effects_player.gd.uid create mode 100644 ui/audio/owniverse_music_player.gd create mode 100644 ui/audio/owniverse_music_player.gd.uid diff --git a/autoload/global.gd b/autoload/global.gd index 397eb04..1d0e804 100644 --- a/autoload/global.gd +++ b/autoload/global.gd @@ -9,6 +9,9 @@ var save_plaintext_copy: bool = true var Settings: Dictionary = { "filename": "save-0001", "sound_volume": 1.0, + "music_volume": 1.0, + "sound_enabled": true, + "music_enabled": true, "language": "en", } @@ -23,6 +26,9 @@ var Templates: Dictionary = {} var LifeRules: Dictionary = {} var CivilizationRules: Dictionary = {} +var Music: Dictionary = {} +var Effects: Dictionary = {} + var batch_count: float = 40.0 # must be even not odd @@ -70,6 +76,9 @@ func _ready() -> void: Templates = OwniverseTemplateLoader.get_data() CivilizationRules = OwniverseCivilizationRuleLoader.get_data() + Music = OwniverseAudioLoader.get_music() + Effects = OwniverseAudioLoader.get_effects() + func _read_folder_list(folder_name: String) -> void: var dir := DirAccess.open(folder_name) diff --git a/data/owniverse_audio_loader.gd b/data/owniverse_audio_loader.gd new file mode 100644 index 0000000..e8d6a87 --- /dev/null +++ b/data/owniverse_audio_loader.gd @@ -0,0 +1,40 @@ +class_name OwniverseAudioLoader + +const MUSIC: Dictionary = { + "apocalypse_the_end": preload("res://sound/music/apocalypse_the_end.ogg"), + "cosmic_unicorns": preload("res://sound/music/cosmic_unicorns.ogg"), + "funky_eldritch": preload("res://sound/music/funky_eldritch.ogg"), + "meditative_beings": preload("res://sound/music/meditative_beings.ogg"), + "owniverse_main_theme": preload("res://sound/music/owniverse_main_theme.ogg"), +} + +const EFFECTS: Dictionary = { + "black_hole_feed": preload("res://sound/effects/black_hole_feed.ogg"), + "blast": preload("res://sound/effects/blast.ogg"), + "civ_birth": preload("res://sound/effects/civ_birth.ogg"), + "civ_death": preload("res://sound/effects/civ_death.ogg"), + "civ_top": preload("res://sound/effects/civ_top.ogg"), + "cloud_decay": preload("res://sound/effects/cloud_decay.ogg"), + "create": preload("res://sound/effects/create.ogg"), + "force": preload("res://sound/effects/force.ogg"), + "gamma_ray": preload("res://sound/effects/gamma_ray.ogg"), + "life_birth": preload("res://sound/effects/life_birth.ogg"), + "life_death": preload("res://sound/effects/life_death.ogg"), + "select": preload("res://sound/effects/select.ogg"), + "star_birth": preload("res://sound/effects/star_birth.ogg"), + "structure_decay": preload("res://sound/effects/structure_decay.ogg"), + "swipe": preload("res://sound/effects/swipe.ogg"), + "time_minus": preload("res://sound/effects/time_minus.ogg"), + "time_plus": preload("res://sound/effects/time_plus.ogg"), + "trend_action": preload("res://sound/effects/trend_action.ogg"), + "window_close": preload("res://sound/effects/window_close.ogg"), + "window_open": preload("res://sound/effects/window_open.ogg"), +} + + +static func get_music() -> Dictionary: + return MUSIC.duplicate() + + +static func get_effects() -> Dictionary: + return EFFECTS.duplicate() diff --git a/data/owniverse_audio_loader.gd.uid b/data/owniverse_audio_loader.gd.uid new file mode 100644 index 0000000..a8a8808 --- /dev/null +++ b/data/owniverse_audio_loader.gd.uid @@ -0,0 +1 @@ +uid://cb517ydjlk7iq diff --git a/stage/stage.gd b/stage/stage.gd index 29d8b5a..aa0a2f0 100644 --- a/stage/stage.gd +++ b/stage/stage.gd @@ -64,6 +64,7 @@ const SWIPE_DURATION: float = 0.4 @onready var g_time_minus: TextureButton = $World/TopMenu/TimeMinus @onready var g_time_plus: TextureButton = $World/TopMenu/TimePlus +@onready var effects_player: OwniverseEffectsPlayer = $"../FXPlayer" @onready var screens: Dictionary = { "World": $World, @@ -263,12 +264,14 @@ func _swipe_to_nearest_page() -> void: func _input(event: InputEvent) -> void: if event is InputEventScreenDrag: + #effects_player.play_effect("swipe") stage_page_scroller.scroll_horizontal = clamp( stage_page_scroller.scroll_horizontal - PAGE_COUNT * event.screen_relative.x, 0, PAGE_WIDTH * (PAGE_COUNT - 1)) g_counters.swipe.is_active = true g_counters.swipe.value = 0 elif event is InputEventPanGesture: + #effects_player.play_effect("swipe") g_counters.swipe.is_active = true g_counters.swipe.value = 0 elif event is InputEventScreenTouch: diff --git a/tests/test_effects_player.gd b/tests/test_effects_player.gd new file mode 100644 index 0000000..82be2e4 --- /dev/null +++ b/tests/test_effects_player.gd @@ -0,0 +1,47 @@ +extends SceneTree + +var checks: int = 0 +var failures: int = 0 + + +func _initialize() -> void: + call_deferred("run") + + +func check(condition: bool, message: String) -> void: + checks += 1 + if not condition: + failures += 1 + push_error(message) + + +func run() -> void: + var player := OwniverseEffectsPlayer.new() + root.add_child(player) + + check(player.play_effect("select"), "Known effect starts") + check(player.stream == player._effects.select and player.playing, "Selected effect is playing") + var selected_stream: AudioStream = player.stream + check(not player.play_effect("select"), "Playing effect is not restarted") + check(player.stream == selected_stream and player.playing, "Rejected duplicate keeps current playback") + player.stop() + check(not player.play_effect("select"), "Named effect is rejected during its two-second cooldown") + check(player.play_effect("blast"), "Different effect starts without waiting for another name") + player.stop() + player._last_played_msec.select -= player.EFFECT_COOLDOWN_MSEC + check(player.play_effect("select"), "Named effect starts after its cooldown") + check(not player.play_effect("missing_effect"), "Unknown effect is rejected") + check(player.stream == player._effects.select, "Unknown effect does not interrupt playback") + + player.set_enabled(false) + check(player.stream_paused, "Disabling effects pauses current playback") + check(not player.play_effect("blast") and player.stream == player._effects.select, "Disabled player rejects new effects") + player.set_enabled(true) + check(not player.stream_paused, "Enabling effects resumes current playback") + + player.stop() + player.stream = null + player.free() + await process_frame + print("Effects player: %d checks, %d failures" % [checks, failures]) + quit(0 if failures == 0 else 1) diff --git a/tests/test_effects_player.gd.uid b/tests/test_effects_player.gd.uid new file mode 100644 index 0000000..1a3d302 --- /dev/null +++ b/tests/test_effects_player.gd.uid @@ -0,0 +1 @@ +uid://cj2kk1vx5umtc diff --git a/tests/test_music_player.gd b/tests/test_music_player.gd new file mode 100644 index 0000000..bc3781d --- /dev/null +++ b/tests/test_music_player.gd @@ -0,0 +1,54 @@ +extends SceneTree + +var checks: int = 0 +var failures: int = 0 + + +func _initialize() -> void: + call_deferred("run") + + +func check(condition: bool, message: String) -> void: + checks += 1 + if not condition: + failures += 1 + push_error(message) + + +func run() -> void: + var player := OwniverseMusicPlayer.new() + root.add_child(player) + await process_frame + + check(player.last_track == "", "Music player starts with silence") + check(player._pause_timer.time_left >= player.MIN_PAUSE_SECONDS and player._pause_timer.time_left <= player.MAX_PAUSE_SECONDS, "Initial pause is between two and five minutes") + player._pause_timer.stop() + player._play_next() + check(player.last_track != "", "Music player selects an initial track") + check(player.last_track != player.EXCLUDED_TRACK, "Normal playlist excludes apocalypse") + var previous: String = player.last_track + player._play_next() + check(player.last_track != previous, "Consecutive tracks do not repeat") + check(player.last_track != player.EXCLUDED_TRACK, "Subsequent tracks exclude apocalypse") + + var last_normal_track: String = player.last_track + player.play_apocalypse() + check(player.stream == player._music[player.EXCLUDED_TRACK] and player.playing, "Apocalypse music starts immediately") + check(player.last_track == last_normal_track, "Apocalypse does not replace the last normal track") + + player._schedule_pause() + check(player._pause_timer.time_left >= player.MIN_PAUSE_SECONDS and player._pause_timer.time_left <= player.MAX_PAUSE_SECONDS, "Pause is between two and five minutes") + player.set_enabled(false) + check(player.stream_paused and player._pause_timer.paused, "Disabling music pauses playback and scheduling") + var disabled_stream: AudioStream = player.stream + player.play_apocalypse() + check(player.stream == disabled_stream, "Apocalypse does not start while music is disabled") + player.set_enabled(true) + check(not player.stream_paused and not player._pause_timer.paused, "Enabling music resumes playback and scheduling") + + player.stop() + player.stream = null + player.free() + await process_frame + print("Music player: %d checks, %d failures" % [checks, failures]) + quit(0 if failures == 0 else 1) diff --git a/tests/test_music_player.gd.uid b/tests/test_music_player.gd.uid new file mode 100644 index 0000000..5df2499 --- /dev/null +++ b/tests/test_music_player.gd.uid @@ -0,0 +1 @@ +uid://nue2ifdplt25 diff --git a/ui/audio/owniverse_effects_player.gd b/ui/audio/owniverse_effects_player.gd new file mode 100644 index 0000000..d42c2bd --- /dev/null +++ b/ui/audio/owniverse_effects_player.gd @@ -0,0 +1,31 @@ +class_name OwniverseEffectsPlayer +extends AudioStreamPlayer + +const EFFECT_COOLDOWN_MSEC := 2000 + +var enabled: bool = true +var _effects: Dictionary = OwniverseAudioLoader.get_effects() +var _last_played_msec: Dictionary = {} + + +func set_enabled(value: bool) -> void: + enabled = value + stream_paused = not enabled + + +func play_effect(effect_id: String) -> bool: + if not enabled: + return false + if not _effects.has(effect_id): + push_warning("Unknown sound effect: " + effect_id) + return false + if playing and stream == _effects[effect_id]: + return false + var now: int = Time.get_ticks_msec() + if now - int(_last_played_msec.get(effect_id, -EFFECT_COOLDOWN_MSEC)) < EFFECT_COOLDOWN_MSEC: + return false + + stream = _effects[effect_id] + play() + _last_played_msec[effect_id] = now + return true diff --git a/ui/audio/owniverse_effects_player.gd.uid b/ui/audio/owniverse_effects_player.gd.uid new file mode 100644 index 0000000..1bf3b07 --- /dev/null +++ b/ui/audio/owniverse_effects_player.gd.uid @@ -0,0 +1 @@ +uid://botb0k4qr0hqo diff --git a/ui/audio/owniverse_music_player.gd b/ui/audio/owniverse_music_player.gd new file mode 100644 index 0000000..0896b03 --- /dev/null +++ b/ui/audio/owniverse_music_player.gd @@ -0,0 +1,69 @@ +class_name OwniverseMusicPlayer +extends AudioStreamPlayer + +const EXCLUDED_TRACK := "apocalypse_the_end" +const MIN_PAUSE_SECONDS := 120.0 +const MAX_PAUSE_SECONDS := 300.0 + +var enabled: bool = true +var last_track: String = "" +var _pause_timer: Timer +var _rng := RandomNumberGenerator.new() +var _music: Dictionary = OwniverseAudioLoader.get_music() + + +func _ready() -> void: + _rng.randomize() + _pause_timer = Timer.new() + _pause_timer.one_shot = true + _pause_timer.timeout.connect(_play_next) + add_child(_pause_timer) + finished.connect(_schedule_pause) + if enabled: + _schedule_pause() + + +func set_enabled(value: bool) -> void: + enabled = value + if not is_node_ready(): + return + + stream_paused = not enabled + _pause_timer.paused = not enabled + if enabled and not playing and _pause_timer.is_stopped(): + _play_next() + + +func play_apocalypse() -> void: + if not enabled: + return + if not _music.has(EXCLUDED_TRACK): + push_warning("Apocalypse music is unavailable.") + return + + _pause_timer.stop() + stream = _music[EXCLUDED_TRACK] + play() + + +func _play_next() -> void: + if not enabled: + return + + var candidates: Array = _music.keys() + candidates.erase(EXCLUDED_TRACK) + candidates.erase(last_track) + if candidates.is_empty(): + push_warning("No music tracks available for playback.") + return + + var track_id: String = str(candidates[_rng.randi_range(0, candidates.size() - 1)]) + last_track = track_id + stream = _music[track_id] + play() + + +func _schedule_pause() -> void: + if not enabled: + return + _pause_timer.start(_rng.randf_range(MIN_PAUSE_SECONDS, MAX_PAUSE_SECONDS)) diff --git a/ui/audio/owniverse_music_player.gd.uid b/ui/audio/owniverse_music_player.gd.uid new file mode 100644 index 0000000..7330a1c --- /dev/null +++ b/ui/audio/owniverse_music_player.gd.uid @@ -0,0 +1 @@ +uid://btsrt6rw3nr04 diff --git a/ui/main_menu/main_menu.gd b/ui/main_menu/main_menu.gd index 2076b6e..c7f564b 100644 --- a/ui/main_menu/main_menu.gd +++ b/ui/main_menu/main_menu.gd @@ -1 +1,67 @@ extends VBoxContainer + +const MIN_VOLUME_DB := -50.0 +const SILENT_VOLUME_DB := -80.0 + +@onready var sound_button: TextureButton = $Media/Sound +@onready var music_button: TextureButton = $Media/Music +@onready var sound_volume: HSlider = $Volume/SoundVolume +@onready var music_volume: HSlider = $Volume/MusicVolume +@onready var effects_player: OwniverseEffectsPlayer = $"../../FXPlayer" +@onready var music_player: OwniverseMusicPlayer = $"../../MusicPlayer" + + +func _ready() -> void: + SaveManager.load_settings() + sound_button.set_pressed_no_signal(bool(Global.Settings.sound_enabled)) + music_button.set_pressed_no_signal(bool(Global.Settings.music_enabled)) + sound_volume.set_value_no_signal(float(Global.Settings.sound_volume) * sound_volume.max_value) + music_volume.set_value_no_signal(float(Global.Settings.music_volume) * music_volume.max_value) + _apply_sound_settings() + _apply_music_settings() + + +func _on_sound_pressed() -> void: + Global.Settings.sound_enabled = sound_button.button_pressed + _apply_sound_settings() + SaveManager.save_settings() + + +func _on_music_pressed() -> void: + Global.Settings.music_enabled = music_button.button_pressed + _apply_music_settings() + SaveManager.save_settings() + + +func _on_sound_volume_value_changed(value: float) -> void: + Global.Settings.sound_volume = value / sound_volume.max_value + _apply_bus_volume("FX", float(Global.Settings.sound_volume)) + SaveManager.save_settings() + + +func _on_music_volume_value_changed(value: float) -> void: + Global.Settings.music_volume = value / music_volume.max_value + _apply_bus_volume("Music", float(Global.Settings.music_volume)) + SaveManager.save_settings() + + +func _apply_sound_settings() -> void: + var bus_index: int = AudioServer.get_bus_index("FX") + AudioServer.set_bus_mute(bus_index, not bool(Global.Settings.sound_enabled)) + effects_player.set_enabled(bool(Global.Settings.sound_enabled)) + _apply_bus_volume("FX", float(Global.Settings.sound_volume)) + + +func _apply_music_settings() -> void: + music_player.set_enabled(bool(Global.Settings.music_enabled)) + _apply_bus_volume("Music", float(Global.Settings.music_volume)) + + +func _apply_bus_volume(bus_name: String, value: float) -> void: + var bus_index: int = AudioServer.get_bus_index(bus_name) + if bus_index < 0: + push_warning("Missing audio bus: " + bus_name) + return + var normalized: float = clampf(value, 0.0, 1.0) + var volume_db: float = SILENT_VOLUME_DB if is_zero_approx(normalized) else lerpf(MIN_VOLUME_DB, 0.0, normalized) + AudioServer.set_bus_volume_db(bus_index, volume_db) diff --git a/world/world.tscn b/world/world.tscn index 4d157d1..927a068 100644 --- a/world/world.tscn +++ b/world/world.tscn @@ -67,6 +67,8 @@ [ext_resource type="Texture2D" uid="uid://drvvkd724g8hy" path="res://ui/credits/kid_gp_light.png" id="64_rur7c"] [ext_resource type="Texture2D" uid="uid://b33ji4mpyw4ks" path="res://ui/credits/kid_gp.png" id="65_y1rwn"] [ext_resource type="Texture2D" uid="uid://dgt52qiyuirso" path="res://ui/indicators/density/warning_border.png" id="66_rur7c"] +[ext_resource type="Script" uid="uid://btsrt6rw3nr04" path="res://ui/audio/owniverse_music_player.gd" id="67_music"] +[ext_resource type="Script" uid="uid://botb0k4qr0hqo" path="res://ui/audio/owniverse_effects_player.gd" id="68_effects"] [sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ifjjt"] @@ -1116,8 +1118,12 @@ wait_time = 2.0 autostart = true [node name="FXPlayer" type="AudioStreamPlayer" parent="." unique_id=1306204231] +bus = &"FX" +script = ExtResource("68_effects") [node name="MusicPlayer" type="AudioStreamPlayer" parent="." unique_id=707734847] +bus = &"Music" +script = ExtResource("67_music") [connection signal="timeout" from="Background/Timer" to="Background" method="randomize_parallax"] [connection signal="pressed" from="Stage/World/TopMenu/BurgerButton" to="Stage" method="_on_topmenu_button_pressed" binds= ["MainMenu"]] @@ -1135,6 +1141,10 @@ autostart = true [connection signal="pressed" from="Stage/MainMenu/TopMenu/BackButton" to="Stage" method="_on_topmenu_button_pressed" binds= ["World"]] [connection signal="pressed" from="Stage/MainMenu/CreateOwniverse" to="Stage" method="create_new_universe"] [connection signal="pressed" from="Stage/MainMenu/Achievements" to="Stage" method="_on_topmenu_button_pressed" binds= ["Achievements"]] +[connection signal="pressed" from="Stage/MainMenu/Media/Sound" to="Stage/MainMenu" method="_on_sound_pressed"] +[connection signal="pressed" from="Stage/MainMenu/Media/Music" to="Stage/MainMenu" method="_on_music_pressed"] +[connection signal="value_changed" from="Stage/MainMenu/Volume/SoundVolume" to="Stage/MainMenu" method="_on_sound_volume_value_changed"] +[connection signal="value_changed" from="Stage/MainMenu/Volume/MusicVolume" to="Stage/MainMenu" method="_on_music_volume_value_changed"] [connection signal="pressed" from="Stage/MainMenu/Credits" to="Stage" method="_on_topmenu_button_pressed" binds= ["Credits"]] [connection signal="pressed" from="Stage/Report/TopMenu/BackButton" to="Stage" method="_on_topmenu_button_pressed" binds= ["World"]] [connection signal="pressed" from="Stage/Achievements/TopMenu/BackButton" to="Stage" method="_on_topmenu_button_pressed" binds= ["World"]]