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

+69 -7
View File
@@ -16,7 +16,7 @@ panel = NodePath("SettingsPanel")
unique_name_in_owner = true
layout_mode = 0
offset_right = 520.0
offset_bottom = 242.0
offset_bottom = 270.0
theme_type_variation = &"PopupBackground"
[node name="VBoxContainer" type="VBoxContainer" parent="SettingsPanel" unique_id=1796635163]
@@ -25,7 +25,7 @@ layout_mode = 0
offset_left = 20.0
offset_top = 12.0
offset_right = 500.0
offset_bottom = 230.0
offset_bottom = 258.0
[node name="Label" type="Label" parent="SettingsPanel/VBoxContainer" unique_id=1111531402]
layout_mode = 2
@@ -47,6 +47,65 @@ layout_mode = 2
size_flags_horizontal = 3
focus_mode = 0
[node name="FolderRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/FolderRow"]
layout_mode = 2
theme_type_variation = &"SettingsRowLabel"
text = "Music folder"
[node name="FolderLabel" type="Label" parent="SettingsPanel/VBoxContainer/FolderRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 1
text = "None, using the server"
text_overrun_behavior = 3
[node name="ChooseFolderButton" type="Button" parent="SettingsPanel/VBoxContainer/FolderRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Choose…"
[node name="UseServerButton" type="Button" parent="SettingsPanel/VBoxContainer/FolderRow"]
unique_name_in_owner = true
visible = false
layout_mode = 2
focus_mode = 0
text = "Use server"
[node name="FolderErrorLabel" type="Label" parent="SettingsPanel/VBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"HighlightMedium"
text = "No songs found"
text_overrun_behavior = 3
[node name="PlayOrderRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/PlayOrderRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"SettingsRowLabel"
text = "Play the prepared quiz"
[node name="PlayOrderButton" type="OptionButton" parent="SettingsPanel/VBoxContainer/PlayOrderRow"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
selected = 0
item_count = 2
popup/item_0/text = "In order"
popup/item_0/id = 0
popup/item_1/text = "At random"
popup/item_1/id = 1
[node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/VBoxContainer" unique_id=400304684]
unique_name_in_owner = true
layout_mode = 2
@@ -99,9 +158,12 @@ focus_mode = 0
action_mode = 0
text = "Count plays in statistics"
[node name="LocalButton" type="CheckButton" parent="SettingsPanel/VBoxContainer" unique_id=905844805]
[node name="FolderDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
visible = false
layout_mode = 2
disabled = true
text = "Local"
title = "Choose a music folder"
initial_position = 2
size = Vector2i(800, 500)
ok_button_text = "Select Current Folder"
file_mode = 2
access = 2
use_native_dialog = true
+4 -1
View File
@@ -71,8 +71,11 @@ func open() -> void:
whats_new_label.text = changelog_text(entries)
# Asked every time, as another server can be picked in the settings
server_version_label.text = "Loading…"
server_history_label.text = ""
if Settings.is_offline():
server_version_label.text = "Not connected, playing from a music folder"
return
server_version_label.text = "Loading…"
MusicServerClient.fetch_version_history(_show_server_history, _show_server_history_failed)
+3 -1
View File
@@ -20,7 +20,7 @@ var _winning_score: int = 0
## Shows [param history]. The y axis goes up to [param winning_score], or higher if
## someone has more points.
## someone has more points. A winning score of 0 draws no winning line.
func show_history(history: ScoreHistory, winning_score: int) -> void:
_history = history
_winning_score = winning_score
@@ -117,6 +117,8 @@ func _draw_score_axis(font: Font, font_size: int, text_color: Color) -> void:
draw_line(Vector2(MARGIN_LEFT, y), Vector2(size.x - MARGIN_RIGHT, y), grid_color, 1)
draw_string(font, Vector2(0, y + font_size * 0.35), str(score),
HORIZONTAL_ALIGNMENT_RIGHT, MARGIN_LEFT - 8, font_size, text_color)
if _winning_score <= 0:
return
var winning_color: Color = get_theme_color(&"winning_line", THEME_TYPE)
var winning_y: float = _y(_winning_score)
draw_dashed_line(Vector2(MARGIN_LEFT, winning_y), Vector2(size.x - MARGIN_RIGHT, winning_y),
+3
View File
@@ -80,6 +80,9 @@ func shown_names() -> Array[String]:
func get_list_of_soundtracks() -> void:
GameLog.debug("get_list_of_soundtracks")
if Settings.is_offline():
set_soundtracks(Settings.music_folder.soundtrack_names())
return
var handle_soundtracks: Callable = func handle_soundtracks(array: Variant) -> void:
if typeof(array) == TYPE_ARRAY:
set_soundtracks(array)
+83 -8
View File
@@ -1,14 +1,21 @@
class_name SettingsPopup
extends BasePopup
## The application settings: which MusicServer to use, fullscreen, how many songs to
## download ahead, whether to prefer less played soundtracks and whether plays count in
## the statistics. Opened from the Settings button in the top bar.
## The application settings: which MusicServer or music folder to play from, fullscreen,
## how many songs to download ahead, whether to prefer less played soundtracks and whether
## plays count in the statistics. Opened from the Settings button in the top bar.
##
## A music folder (see [MusicFolder]) is used instead of the server until Use server is
## pressed or the game is closed. The settings that need the server are off meanwhile.
##
## The quiz settings, such as the winning score, are in [QuizSettingsCard].
## Emitted when another MusicServer is picked.
## Emitted when another MusicServer or music folder is picked, or a prepared quiz's play
## order is changed. A new game starts.
signal server_changed
## Space between the rows and the panel's edges.
const PADDING: Vector2 = Vector2(20, 12)
@onready var settings_panel: Panel = %SettingsPanel
@onready var select_server_button: OptionButton = %SelectServerButton
@onready var fullscreen_button: CheckButton = %FullscreenButton
@@ -17,7 +24,17 @@ signal server_changed
@onready var increase_cache_button: Button = %IncreaseCacheButton
@onready var low_played_button: CheckButton = %LowPlayedCheckButton
@onready var add_to_database_button: CheckButton = %AddToDatabaseCheckButton
@onready var local_button: CheckButton = %LocalButton
@onready var rows: VBoxContainer = %VBoxContainer
@onready var folder_label: Label = %FolderLabel
@onready var choose_folder_button: Button = %ChooseFolderButton
@onready var use_server_button: Button = %UseServerButton
@onready var folder_error_label: Label = %FolderErrorLabel
@onready var play_order_row: HBoxContainer = %PlayOrderRow
@onready var play_order_button: OptionButton = %PlayOrderButton
@onready var folder_dialog: FileDialog = %FolderDialog
# Where the panel's middle is, so it stays centred when rows are shown or hidden
var _panel_middle_y: float = 0.0
func _ready() -> void:
@@ -28,7 +45,10 @@ func _ready() -> void:
cache_label.text = str(Playlist.number_of_tracks_to_preload)
fullscreen_button.pressed.connect(fullscreen)
local_button.pressed.connect(local_play)
choose_folder_button.pressed.connect(folder_dialog.popup_centered)
folder_dialog.dir_selected.connect(use_music_folder)
use_server_button.pressed.connect(use_server)
play_order_button.item_selected.connect(_on_play_order_selected)
lower_cache_button.pressed.connect(lower_cache)
increase_cache_button.pressed.connect(increase_cache)
select_server_button.item_selected.connect(select_server)
@@ -38,14 +58,34 @@ func _ready() -> void:
visibility_changed.connect(_on_visibility_changed)
# Alt+Enter switches fullscreen from the main window, which resizes the window
get_window().size_changed.connect(_update_fullscreen_button)
_panel_middle_y = settings_panel.position.y + settings_panel.size.y / 2
_show_music_source()
func fullscreen() -> void:
Settings.set_fullscreen(!Settings.fullscreen)
func local_play() -> void:
Settings.play_local = !Settings.play_local
## Plays songs from the folder at [param path] instead of the server's. A folder with
## nothing to play is not used, and the popup says so.
func use_music_folder(path: String) -> void:
var folder: MusicFolder = MusicFolder.scan(path)
if folder.kind == MusicFolder.Kind.EMPTY:
folder_error_label.text = "No songs found in " + folder.display_name()
folder_error_label.visible = true
_fit_to_rows()
return
folder.set_play_order(play_order_button.selected as MusicFolder.PlayOrder)
Settings.music_folder = folder
_show_music_source()
server_changed.emit()
## Plays from the server again instead of the music folder.
func use_server() -> void:
Settings.music_folder = null
_show_music_source()
server_changed.emit()
func lower_cache() -> void:
@@ -83,6 +123,41 @@ func _on_visibility_changed() -> void:
_update_fullscreen_button()
low_played_button.set_pressed_no_signal(Settings.use_low_played_mode)
add_to_database_button.set_pressed_no_signal(Settings.add_to_stats)
else:
folder_error_label.visible = false
_fit_to_rows()
func _on_play_order_selected(order: int) -> void:
if Settings.is_offline():
Settings.music_folder.set_play_order(order as MusicFolder.PlayOrder)
server_changed.emit()
# Shows where songs come from, and turns off what needs the server while offline
func _show_music_source() -> void:
var folder: MusicFolder = Settings.music_folder
folder_error_label.visible = false
use_server_button.visible = folder != null
if folder == null:
folder_label.text = "None, using the server"
folder_label.tooltip_text = ""
else:
folder_label.text = folder.display_name()
folder_label.tooltip_text = folder.path
play_order_row.visible = folder != null && folder.kind == MusicFolder.Kind.PREPARED_QUIZ
for control: BaseButton in [select_server_button, low_played_button, add_to_database_button]:
control.disabled = folder != null
_fit_to_rows()
# Grows or shrinks the panel to fit the rows shown, keeping it centred
func _fit_to_rows() -> void:
var height: float = rows.get_combined_minimum_size().y + PADDING.y * 2
settings_panel.size.y = height
settings_panel.position.y = floorf(_panel_middle_y - height / 2)
rows.position = PADDING
rows.size = Vector2(settings_panel.size.x - PADDING.x * 2, height - PADDING.y * 2)
func _update_fullscreen_button() -> void:
+72 -20
View File
@@ -22,43 +22,95 @@ func _ready() -> void:
func show_winner(winner: PlayerObject) -> void:
var winners: Array[PlayerObject] = [winner]
show_winners(winners)
## Shows [param winners], who share the top score, with everyone else's placing below.
## Tied players get the same placing. With no players, it says the quiz is over.
func show_winners(winners: Array[PlayerObject]) -> void:
visible = true
winner_label.text = winner.player_name + " won with " + str(winner.player_score) + \
" points!!"
winner_picture.texture = winner.character
# The "no character" placeholder is pixel art, which blurs when scaled up smoothly
if winner.character.resource_path == PlayerObject.NO_CHARACTER_PATH:
winner_picture.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
else:
winner_picture.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
winner_label.text = winners_text(winners)
_show_pictures(winners)
# Clear previous player list entries
for child: Node in players_list.get_children():
child.queue_free()
# Sort players by score (descending) and add to list
var sorted_players: Array = Settings.player_array.duplicate()
var sorted_players: Array[PlayerObject] = Settings.player_array.duplicate()
sorted_players.sort_custom(
func(a: PlayerObject, b: PlayerObject) -> bool:
return a.player_score > b.player_score
)
var placing: int = 2
for player: PlayerObject in sorted_players:
if player == winner:
continue # Skip the winner, already shown above
if player in winners:
continue
var label: Label = Label.new()
label.text = str(placing) + ". " + player.player_name + " - " + \
str(player.player_score) + " points"
label.text = "%d. %s - %s" % [placing(player), player.player_name,
points_text(player.player_score)]
players_list.add_child(label)
placing += 1
show_history(ScoreHistory.from_playlist(Settings.player_array))
## The headline for [param winners], e.g. "Crono won with 5 points!!" or "Crono and
## Marle won with 5 points!!".
static func winners_text(winners: Array[PlayerObject]) -> String:
if winners.is_empty():
return "The quiz is over!"
var names: PackedStringArray = []
for winner: PlayerObject in winners:
names.append(winner.player_name)
var joined: String = names[-1]
if names.size() > 1:
joined = ", ".join(names.slice(0, -1)) + " and " + names[-1]
return "%s won with %s!!" % [joined, points_text(winners[0].player_score)]
## [param points] in words, e.g. "1 point" or "5 points".
static func points_text(points: int) -> String:
return "%d point%s" % [points, "" if points == 1 else "s"]
## Where [param player] placed: 1 plus how many players have more points, so tied
## players share a placing.
static func placing(player: PlayerObject) -> int:
var place: int = 1
for other: PlayerObject in Settings.player_array:
if other.player_score > player.player_score:
place += 1
return place
# One picture per winner, next to each other
func _show_pictures(winners: Array[PlayerObject]) -> void:
var header: Node = winner_picture.get_parent()
for extra: Node in header.get_children():
if extra != winner_picture && extra is TextureRect:
header.remove_child(extra)
extra.queue_free()
winner_picture.visible = !winners.is_empty()
for index: int in winners.size():
var picture: TextureRect = winner_picture
if index > 0:
picture = winner_picture.duplicate()
header.add_child(picture)
header.move_child(picture, winner_picture.get_index() + index)
_show_character(picture, winners[index])
func _show_character(picture: TextureRect, player: PlayerObject) -> void:
picture.texture = player.character
# The "no character" placeholder is pixel art, which blurs when scaled up smoothly
if player.character.resource_path == PlayerObject.NO_CHARACTER_PATH:
picture.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
else:
picture.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
## Shows how the game went: the score graph, its legend and the summary boxes.
func show_history(history: ScoreHistory) -> void:
score_graph.show_history(history, Settings.winning_score)
# A prepared quiz has no winning score to draw
var winning_score: int = 0 if Settings.is_prepared_quiz() else Settings.winning_score
score_graph.show_history(history, winning_score)
streak_label.text = history.longest_streak_text()
standstill_label.text = history.longest_standstill_text()
shared_label.text = history.most_shared_text()