diff --git a/audio/Music.gd b/audio/Music.gd new file mode 100644 index 0000000..43e6ef5 --- /dev/null +++ b/audio/Music.gd @@ -0,0 +1,63 @@ +class_name Music +extends RefCounted + +# Forgejo #148: "the space_4 file in the folder is the main theme of the +# game, make it play all the time." +# +# "All the time" is the whole of the requirement, and it is what decides the +# shape of this. The theme must survive the main menu becoming the game, the +# game becoming a planet surface, and the surface becoming the game again - +# so the player cannot live in any of those scenes, because +# change_scene_to_file() frees the one that is current. +# +# It is parented to the TREE ROOT instead, alongside the GameState autoload, +# which is the same trick for the same reason. Doing it this way rather than +# by adding an autoload keeps project.godot out of it - that file carries +# the developer's own uncommitted window settings, and a change here should +# not drag those into a commit. +# +# ensure() is idempotent by NAME: whatever scene loads next asks for the +# music, and gets the one already playing rather than a second copy of it. + +const THEME_PATH: String = "res://audio/space_4.mp3" +const NODE_NAME: String = "MusicPlayer" +# Under everything else. It is a bed, not an event. +const VOLUME_DB: float = -14.0 + + +# Starts the theme if nothing is playing it yet, and returns the player +# either way. Safe to call from any scene's _ready. +static func ensure(tree: SceneTree) -> AudioStreamPlayer: + if tree == null: + return null + var existing: Node = tree.root.get_node_or_null(NODE_NAME) + if existing != null: + return existing as AudioStreamPlayer + + var stream: AudioStream = load(THEME_PATH) + if stream == null: + return null + # Looping is a property of the STREAM, not of the player, so it is set + # here rather than relied on from the .import file - which Godot + # regenerates, and which would silently take the loop away with it. + if "loop" in stream: + stream.loop = true + + var player := AudioStreamPlayer.new() + player.name = NODE_NAME + player.stream = stream + player.volume_db = VOLUME_DB + player.autoplay = false + # #128 stops the tree whenever a dialog is up. Music is the one thing + # that should not stop with it - a soundtrack cutting out every time + # someone reads a line is worse than no soundtrack. + player.process_mode = Node.PROCESS_MODE_ALWAYS + # A backstop for the loop above: if a stream format ever turns up that + # cannot loop itself, it still comes round again rather than falling + # silent for the rest of the session. + player.finished.connect(player.play) + # Deferred: this is called from a scene's _ready, and the root is busy + # adding that scene's own children at the time. + tree.root.add_child.call_deferred(player) + player.play.call_deferred() + return player diff --git a/audio/Music.gd.uid b/audio/Music.gd.uid new file mode 100644 index 0000000..ff0fdd3 --- /dev/null +++ b/audio/Music.gd.uid @@ -0,0 +1 @@ +uid://bwck7xas3hae3 diff --git a/audio/space_4.mp3 b/audio/space_4.mp3 new file mode 100644 index 0000000..964ff2f Binary files /dev/null and b/audio/space_4.mp3 differ diff --git a/audio/space_4.mp3.import b/audio/space_4.mp3.import new file mode 100644 index 0000000..4edbc03 --- /dev/null +++ b/audio/space_4.mp3.import @@ -0,0 +1,19 @@ +[remap] + +importer="mp3" +type="AudioStreamMP3" +uid="uid://bks0l0gbo0k48" +path="res://.godot/imported/space_4.mp3-c5b265d027b77338d1ac353a03658ba9.mp3str" + +[deps] + +source_file="res://audio/space_4.mp3" +dest_files=["res://.godot/imported/space_4.mp3-c5b265d027b77338d1ac353a03658ba9.mp3str"] + +[params] + +loop=true +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/controllers/GameController.gd b/controllers/GameController.gd index bd605b3..e893ffb 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -102,6 +102,10 @@ var keyboard_steering: bool = false func _ready() -> void: + # #148: the main theme, which outlives this scene - see Music.ensure(). + # This script is the root of BOTH the space scene and the planet + # surface, so one call here covers the whole game. + Music.ensure(get_tree()) if joystick != null: joystick.direction_changed.connect(_on_joystick_direction) diff --git a/tests/test_music_theme.gd b/tests/test_music_theme.gd new file mode 100644 index 0000000..f0cf2f0 --- /dev/null +++ b/tests/test_music_theme.gd @@ -0,0 +1,149 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_music_theme.gd +# Forgejo #148: the main theme plays all the time. +# +# "All the time" is the requirement, so what is checked is CONTINUITY rather +# than "a sound was started once": the same player has to survive the main +# menu becoming the game, the game becoming a planet surface, and the +# surface becoming the game again - and there must never be two of it, +# because a second copy is the theme playing over itself. +# +# Covers: +# A. loading a scene starts the theme +# B. it is parented to the tree root, not to the scene - the thing that +# makes it survive change_scene_to_file at all +# C. the stream loops, so "all the time" outlasts one play-through of it +# D. it keeps playing while a dialog has the game stopped (#128) +# E. swapping scenes neither restarts it nor stacks a second one up + +var game_state: Node +var scene: Node +var player: AudioStreamPlayer +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() + + +func _fail(message: String) -> bool: + print("TEST_MUSIC_THEME: FAIL (%s)" % message) + quit(1) + return true + + +func _players() -> Array: + var found: Array = [] + for node in root.get_children(): + if node is AudioStreamPlayer: + found.append(node) + return found + + +func _load(path: String) -> void: + scene = load(path).instantiate() + root.add_child(scene) + current_scene = scene + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if not _players().is_empty(): + return _fail("something was already playing before any scene loaded") + _load("res://world/Main.tscn") + elapsed = 0.0 + stage = 1 + return false + 1: + # The player is added deferred, so it arrives a frame later. + if elapsed < 0.4: + return false + # --- A and B. it exists, at the root --------------------- + var players: Array = _players() + if players.size() != 1: + return _fail("%d music players at the root, expected 1" % players.size()) + player = players[0] + if player.get_parent() != root: + return _fail("the music player hangs off %s - it would die with the scene" \ + % player.get_parent()) + if not player.playing: + return _fail("the theme is not playing") + if player.stream == null: + return _fail("the music player has no stream on it") + print("theme playing from %s, parented to the tree root" % player.stream.resource_path) + + # --- C. and it comes round again ------------------------- + if not ("loop" in player.stream) or not player.stream.loop: + return _fail("the theme does not loop - it would fall silent after one play") + if player.stream.get_length() < 10.0: + return _fail("the theme is %.1fs long - that is not a main theme" \ + % player.stream.get_length()) + print("%.0fs of theme, looping" % player.stream.get_length()) + + # --- D. and a dialog does not stop it -------------------- + var narration: Control = scene.get_node("UI/NarrationBanner") + narration.say("Stopping everything else.", "TEST") + if not paused: + return _fail("the dialog did not stop the game, so this proves nothing") + if not player.can_process(): + return _fail("the music stops whenever a dialog is up") + print("a dialog stops the game and the music keeps playing") + while narration.dismiss(): + pass + + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + # --- E. through a scene change --------------------------- + game_state.planet_name = "Music Test" + game_state.planet_id = "music:test" + game_state.planet_definition_id = "rocky" + scene.free() + _load("res://surface/PlanetSurface.tscn") + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.4: + return false + var players2: Array = _players() + if players2.size() != 1: + return _fail("%d music players after a scene change - the theme is playing over itself" \ + % players2.size()) + if players2[0] != player: + return _fail("the scene change replaced the music player, restarting the theme") + if not player.playing: + return _fail("the theme stopped when the scene changed") + print("scene changed to a planet surface: same player, still playing") + + scene.free() + _load("res://world/Main.tscn") + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.4: + return false + if _players().size() != 1 or _players()[0] != player: + return _fail("coming back from a surface left %d players" % _players().size()) + if not player.playing: + return _fail("the theme stopped on the way back from a surface") + print("and back again: still one player, still the same one") + + print("TEST_MUSIC_THEME: PASS") + quit(0) + return true + return false diff --git a/tests/test_music_theme.gd.uid b/tests/test_music_theme.gd.uid new file mode 100644 index 0000000..c369daf --- /dev/null +++ b/tests/test_music_theme.gd.uid @@ -0,0 +1 @@ +uid://bafwt54rgcc5l diff --git a/ui/MainMenu.gd b/ui/MainMenu.gd index 129203d..3f6ed71 100644 --- a/ui/MainMenu.gd +++ b/ui/MainMenu.gd @@ -34,6 +34,9 @@ const STATUS_HEIGHT: float = 70.0 func _ready() -> void: + # #148: the theme starts here, at the first screen, and keeps playing + # through every scene after it. + Music.ensure(get_tree()) play_button.pressed.connect(_on_play_pressed) editor_button.pressed.connect(_on_editor_pressed) editor_button.enabled = true