#148: the main theme plays, and keeps playing
"All the time" is the whole of the requirement and it decides the shape. The theme has to 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 whichever one is current. It is parented to the tree root instead, beside the GameState autoload, which is the same trick for the same reason. Music.ensure() is idempotent by node name, so each scene asks for the music on the way up and gets the one already playing rather than a second copy over the top of it. Two call sites cover everything: MainMenu, and GameController - which is the root script of BOTH the space scene and the planet surface. Done this way rather than as an autoload deliberately: project.godot carries the developer's own uncommitted window settings, and adding music should not drag those into a commit. It is also PROCESS_MODE_ALWAYS. Since #128 a dialog stops the tree, and a soundtrack that cuts out every time someone reads a line is worse than no soundtrack. The theme ships as MP3 rather than the 59MB WAV that was sitting in the project root. Same 5m36s, 6.7MB, encoded with LAME at 160k - 89% smaller, which is the difference between a repository that carries its music and one that carries a WAV forever in its history. The original file is untouched and still there, untracked, so nothing is lost. The test checks CONTINUITY rather than "a sound started": the same player, never a second one, through two scene changes and a dialog.
This commit is contained in:
parent
2206b55873
commit
aabd5804cf
8 changed files with 240 additions and 0 deletions
63
audio/Music.gd
Normal file
63
audio/Music.gd
Normal file
|
|
@ -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
|
||||||
1
audio/Music.gd.uid
Normal file
1
audio/Music.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bwck7xas3hae3
|
||||||
BIN
audio/space_4.mp3
Normal file
BIN
audio/space_4.mp3
Normal file
Binary file not shown.
19
audio/space_4.mp3.import
Normal file
19
audio/space_4.mp3.import
Normal file
|
|
@ -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
|
||||||
|
|
@ -102,6 +102,10 @@ var keyboard_steering: bool = false
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
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:
|
if joystick != null:
|
||||||
joystick.direction_changed.connect(_on_joystick_direction)
|
joystick.direction_changed.connect(_on_joystick_direction)
|
||||||
|
|
||||||
|
|
|
||||||
149
tests/test_music_theme.gd
Normal file
149
tests/test_music_theme.gd
Normal file
|
|
@ -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.<instance member> 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
|
||||||
1
tests/test_music_theme.gd.uid
Normal file
1
tests/test_music_theme.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bafwt54rgcc5l
|
||||||
|
|
@ -34,6 +34,9 @@ const STATUS_HEIGHT: float = 70.0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
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)
|
play_button.pressed.connect(_on_play_pressed)
|
||||||
editor_button.pressed.connect(_on_editor_pressed)
|
editor_button.pressed.connect(_on_editor_pressed)
|
||||||
editor_button.enabled = true
|
editor_button.enabled = true
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue