Add offline mode: play from a music folder instead of the server
Build / test (push) Successful in 1m2s
Build / build (push) Successful in 1m12s

Settings has a Music folder row. A folder with subfolders is played as
soundtracks: Next picks a random subfolder and a random song in it, the
subfolder is the answer's soundtrack and the file name without its type the
song. The inspiration list and Search show the subfolders.

A folder with songs and no subfolders is a prepared quiz, played once in order
(natural sort) or at random, showing only the song title. It has no winning
score: after the last song Next becomes Show winner, and the players with the
most points win, ties included.

While a folder is used nothing but the characters is asked from the server:
Sync, QR, Statistics, the update check and play counting are off. Characters
fall back to the cached pictures when the server can't be reached. mp3, ogg and
wav play; other files and broken ones are skipped.

The old local playback code, the hidden Open button and the hidden Local toggle
are removed. Next now waits for the song to start before preparing the next one.
This commit is contained in:
Sansan committed 2026-10-07 23:07:47 +02:00
1 parent b120440404
commit 222d231b1b
42 files changed
+8245 -474

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
class_name MusicFolder
extends RefCounted
## A folder of songs to play instead of the MusicServer's, picked in the settings.
##
## A folder with subfolders is a soundtrack folder: each subfolder is a soundtrack, and
## songs are picked at random from a random soundtrack. Songs at the top are left out.
## A folder with songs and no subfolders is a prepared quiz: every song plays once, in
## order or at random, and only the song's name is the answer.
##
## Hidden files and folders, and files the game can't play (see [AudioFile]), are left
## out.
enum Kind { EMPTY, SOUNDTRACKS, PREPARED_QUIZ }
enum PlayOrder { IN_ORDER, RANDOM }
## How many files are tried before giving up when files can't be read.
const MAX_TRIES: int = 20
## The folder's path.
var path: String = ""
var kind: Kind = Kind.EMPTY
## How a prepared quiz is played. Change it with [method set_play_order].
var play_order: PlayOrder = PlayOrder.IN_ORDER
# Soundtrack folder: soundtrack name -> paths of its songs
var _soundtracks: Dictionary[String, PackedStringArray] = {}
# Prepared quiz: the songs in order, the order they are played in and how many are played
var _quiz_songs: PackedStringArray = []
var _quiz_queue: PackedStringArray = []
var _quiz_next: int = 0
## Reads the folder at [param folder_path]. Its [member kind] is [constant Kind.EMPTY]
## when there is nothing to play in it.
static func scan(folder_path: String) -> MusicFolder:
var folder: MusicFolder = MusicFolder.new()
folder.path = folder_path.trim_suffix("/")
var subfolders: PackedStringArray = _visible(DirAccess.get_directories_at(folder.path))
if !subfolders.is_empty():
for subfolder: String in subfolders:
var songs: PackedStringArray = _songs_in(folder.path.path_join(subfolder), true)
if !songs.is_empty():
folder._soundtracks[subfolder] = songs
if !folder._soundtracks.is_empty():
folder.kind = Kind.SOUNDTRACKS
else:
folder._quiz_songs = _songs_in(folder.path, false)
if !folder._quiz_songs.is_empty():
folder.kind = Kind.PREPARED_QUIZ
folder.restart()
return folder
## The folder's name, to show to the user.
func display_name() -> String:
return path.get_file()
## The names of the soundtracks, sorted. Empty for a prepared quiz.
func soundtrack_names() -> Array[String]:
var names: Array[String] = []
names.assign(_soundtracks.keys())
names.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0)
return names
## How many songs a prepared quiz has.
func quiz_size() -> int:
return _quiz_songs.size()
## Plays a prepared quiz in [param order] from its first song.
func set_play_order(order: PlayOrder) -> void:
play_order = order
restart()
## Starts a prepared quiz over from its first song, shuffled again if it is played at
## random.
func restart() -> void:
_quiz_queue = _quiz_songs.duplicate()
if play_order == PlayOrder.RANDOM:
var shuffled: Array = Array(_quiz_queue)
shuffled.shuffle()
_quiz_queue = PackedStringArray(shuffled)
_quiz_next = 0
## The next song to play, or null when a prepared quiz has played every song or no file
## can be read.
func next_song() -> SongObject:
for i: int in MAX_TRIES:
var song_path: String = _pick()
if song_path.is_empty():
return null
if AudioFile.load(song_path) != null:
return _song_object(song_path)
GameLog.warning("Skipping a song that can't be played: ", song_path)
return null
func _pick() -> String:
if kind == Kind.SOUNDTRACKS:
var songs: PackedStringArray = _soundtracks[_soundtracks.keys().pick_random()]
return songs[randi() % songs.size()]
if _quiz_next >= _quiz_queue.size():
return ""
_quiz_next += 1
return _quiz_queue[_quiz_next - 1]
func _song_object(song_path: String) -> SongObject:
var song_object: SongObject = SongObject.new()
song_object.song_path = song_path
song_object.song_title = song_path.get_file().get_basename()
if kind == Kind.SOUNDTRACKS:
# The soundtrack is the subfolder right under the music folder
song_object.soundtrack_title = song_path.trim_prefix(path + "/").get_slice("/", 0)
return song_object
# The playable songs in [param dir], in natural order, also in subfolders if [param deep]
static func _songs_in(dir: String, deep: bool) -> PackedStringArray:
var songs: Array[String] = []
for file_name: String in _visible(DirAccess.get_files_at(dir)):
if AudioFile.is_playable(file_name):
songs.append(dir.path_join(file_name))
songs.sort_custom(func(a: String, b: String) -> bool:
return a.get_file().naturalnocasecmp_to(b.get_file()) < 0)
if deep:
for subfolder: String in _visible(DirAccess.get_directories_at(dir)):
songs.append_array(_songs_in(dir.path_join(subfolder), true))
return PackedStringArray(songs)
static func _visible(names: PackedStringArray) -> PackedStringArray:
var shown: PackedStringArray = []
for entry: String in names:
if !entry.begins_with("."):
shown.append(entry)
return shown
+1
View File
@@ -0,0 +1 @@
uid://bcrem1inl47ve
+20 -16
View File
@@ -38,23 +38,27 @@ static func _generate_uuid() -> String:
hex.substr(16, 4), hex.substr(20, 12)]
## Gives the player a point. With a winning score, the point that reaches it wins; a
## prepared quiz has none, so points just add up until every song has played.
func add_point() -> void:
if player_score < Settings.winning_score:
player_score += 1
player_point_given.emit()
var first_point: bool = player_score == 1 && is_first_point
if player_score == 1:
is_first_point = false
# With a low winning score the first point can also be match point or the win,
# so the more important event is checked first
if player_score == Settings.winning_score:
winner_triggered.emit()
elif player_score == Settings.winning_score - 1:
match_point_triggered.emit()
elif first_point:
first_point_triggered.emit()
else:
point_given_sound.emit()
var has_winning_score: bool = !Settings.is_prepared_quiz()
if has_winning_score && player_score >= Settings.winning_score:
return
player_score += 1
player_point_given.emit()
var first_point: bool = player_score == 1 && is_first_point
if player_score == 1:
is_first_point = false
# With a low winning score the first point can also be match point or the win,
# so the more important event is checked first
if has_winning_score && player_score == Settings.winning_score:
winner_triggered.emit()
elif has_winning_score && player_score == Settings.winning_score - 1:
match_point_triggered.emit()
elif first_point:
first_point_triggered.emit()
else:
point_given_sound.emit()
func minus_point() -> void:
+2 -4
View File
@@ -66,7 +66,7 @@ func song_is_answered(song_no: int = currently_playing_song) -> void:
return
GameLog.debug("show_answer_pressed song_object: ", song_object_array[song_no]._to_string())
song_object_array[song_no].is_answered = true
if Settings.add_to_stats:
if Settings.add_to_stats && !Settings.is_offline():
GameLog.debug("add to stats: ", song_no)
MusicServerClient.mark_song_played(song_no)
@@ -141,9 +141,7 @@ func get_song(song_no: int) -> AudioStream:
var song_object: SongObject = song_object_array[song_no]
if song_object.song_path.is_empty():
return song_object.song
if !FileAccess.file_exists(song_object.song_path):
return null
return AudioStreamMP3.load_from_file(song_object.song_path)
return AudioFile.load(song_object.song_path)
func add_song_to_song_object(song_no: int, mp3: PackedByteArray) -> void:
+15 -2
View File
@@ -61,7 +61,6 @@ var api_password: String = ""
## the config file when it is not one of them.
var servers: Array[String] = SERVERS.duplicate()
var selected_server: int = 0
var is_local: bool = false
var is_debug: bool = false
var stop_after_current: bool = true
var auto_repeat_song: bool = true
@@ -70,7 +69,6 @@ var add_to_stats: bool = true
var use_low_played_mode: bool = false
var winning_score: int = 20
var fullscreen: bool = false
var play_local: bool = false
var inspiration_list_speed: int = 1
var hide_for_seconds: float = 5.0
var hide_beginning: bool = false
@@ -87,6 +85,9 @@ var queue_ids: Dictionary[String, String] = {}
## The version the game last showed the About popup for. When it is not [member version],
## this version is new and the main window opens the About popup to show what is new.
var last_seen_version: String = ""
## The folder songs are played from instead of the server, picked in the settings, or null
## to use the server. Kept for this session only.
var music_folder: MusicFolder = null
## The game version, set in Project Settings > Application > Config > Version.
## Releases are made from it, see RELEASING.md.
@@ -220,6 +221,18 @@ func _saved_value(file: ConfigFile, section: String, key: String, default: Varia
return value
## Whether songs come from a music folder instead of the server. Nothing but the
## characters is asked from the server then.
func is_offline() -> bool:
return music_folder != null
## Whether a prepared quiz is played from a music folder. It has no winning score: the
## game ends when every song has played.
func is_prepared_quiz() -> bool:
return music_folder != null && music_folder.kind == MusicFolder.Kind.PREPARED_QUIZ
func delete_children(node: Node) -> void:
for n: Node in node.get_children():
node.remove_child(n)
+12 -7
View File
@@ -12,8 +12,8 @@ var players_given_point: Array[String] = []
var is_answered: bool = false
var has_played: bool = false
var is_playing: bool = false
## The song's file in [method Playlist.songs_folder]. Read the song with
## [method Playlist.get_song].
## The song's file: a download in [method Playlist.songs_folder], or a song in the music
## folder. Read the song with [method Playlist.get_song].
var song_path: String = ""
## The song itself, only when it could not be saved to a file.
var song: AudioStream
@@ -35,16 +35,21 @@ func get_song_title() -> String:
func get_song_info() -> String:
if Settings.is_debug:
var format_string: String = "%d. %s - %s | played: %s | answered: %s"
return format_string % [(song_number + 1), soundtrack_title, song_title, has_played,
is_answered]
return "%d. %s | played: %s | answered: %s" % [(song_number + 1), _full_title(),
has_played, is_answered]
elif is_answered:
var format_string: String = "%d. %s - %s"
return format_string % [(song_number + 1), soundtrack_title, song_title]
return "%d. %s" % [(song_number + 1), _full_title()]
else:
return "??? - ???"
# "Soundtrack - Song", or only the song in a prepared quiz, which has no soundtracks
func _full_title() -> String:
if soundtrack_title.is_empty():
return song_title
return "%s - %s" % [soundtrack_title, song_title]
func add_point(player_id: String) -> void:
GameLog.debug("add_point")
if !players_given_point.has(player_id):