Save the quiz and continue it after a restart; load characters from a folder
Build / test (push) Successful in 1m25s
Build / build (push) Successful in 1m12s

The quiz is saved to current_quiz.json in the config folder after every
change, and the game goes on where it was when it starts again: the
players with their points and characters, every song with who got a point
for it and whether it was played, answered or is playing, and the songs
downloaded ahead with their files. The song that was playing is ready to
play again. Songs whose files are gone are downloaded again.

On a server, the saved songs are checked against the server's playlist. If
it changed, e.g. after a sync, or another server is used, the quiz starts
over with the same players. A quiz from a music folder continues offline,
including how far a prepared quiz has come. A new game is saved like any
other change, and a file that can't be read starts a new quiz.

Characters can be read from a folder picked in Settings instead of the
server, for this session. A music folder with a characters folder brings
its own characters, e.g. for a themed quiz; that folder is not counted as
a soundtrack. When the characters come from somewhere else, players pick
their characters again.

Closes #11, closes #44
This commit is contained in:
Sansan committed 2026-10-09 18:08:50 +02:00
1 parent 4a934840f6
commit e4352a9f34
33 files changed
+2886 -499

No files matched your search

+2 -2
View File
@@ -57,9 +57,9 @@ func change_character() -> void:
change_character_clicked.emit()
func new_character_selected(character_texture: ImageTexture) -> void:
func new_character_selected(character_texture: Texture, character_name: String) -> void:
GameLog.debug("Back in player list with new")
player_object.character = character_texture
player_object.set_character(character_texture, character_name)
set_player_character()
+130 -4
View File
@@ -6,6 +6,9 @@ extends Control
## The main window shows the quiz below its top bar. Pictures and buttons keep the
## positions they had in the main window, so the quiz fills the whole window and lets
## clicks through where it has nothing.
##
## The quiz is saved after every change and continued when the game starts again, see
## [QuizSave].
## Emitted when another MusicServer is picked in the settings, after the quiz has
## reloaded from it.
@@ -26,6 +29,11 @@ var stats_paused_by_win: bool = false
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
var popup_backdrop: PopupBackdrop
# True from start until the saved quiz's songs are back, so a half restored quiz is
# never saved over the whole one
var _restoring: bool = false
var _save_queued: bool = false
@onready var inspiration_list: InspirationList = $InspirationList
@onready var now_playing: NowPlaying = $NowPlaying
@onready var players_panel: PlayersPanel = $PlayersPanel
@@ -50,7 +58,7 @@ func _ready() -> void:
quiz_settings_button.pressed.connect(quiz_settings_card.toggle)
toolbar.reset_playlist_pressed.connect(reset_playlist)
toolbar.reset_points_pressed.connect(reset_points)
song_loader.song_list_loaded.connect(update_song_list)
song_loader.song_list_loaded.connect(_on_song_list_loaded)
song_loader.song_added.connect(_on_song_added)
character_select_popup.character_selected.connect(_on_new_character_selected)
@@ -60,10 +68,15 @@ func _ready() -> void:
players_panel.change_character_requested.connect(_on_change_character_requested)
music_player.play_next_song.connect(play_next_song)
music_player.update_song_list.connect(update_song_list)
character_select_popup.source_changed.connect(_on_character_source_changed)
song_loader.saved_songs_dropped.connect(_on_saved_songs_dropped)
var saved: QuizSave = QuizSave.read()
_continue_saved_quiz(saved)
inspiration_list.load_soundtracks()
song_loader.load_song_list()
song_loader.load_song_list(saved)
character_select_popup.load_characters()
_load_saved_characters(saved)
func _input(event: InputEvent) -> void:
@@ -151,6 +164,7 @@ func _on_song_added() -> void:
song_controls.set_next_enabled(true)
if Settings.is_debug:
update_song_list()
save_soon()
func update_song_list() -> void:
@@ -159,6 +173,114 @@ func update_song_list() -> void:
now_playing.show_song(Playlist.get_current_soundtrack_title(),
Playlist.get_current_song_title())
music_list_panel.refresh()
save_soon()
## Saves the quiz at the end of this frame, once however many changes it had.
func save_soon() -> void:
if _restoring || _save_queued:
return
_save_queued = true
_save_now.call_deferred()
func _save_now() -> void:
_save_queued = false
if is_inside_tree():
QuizSave.capture(stats_paused_by_win).write()
# Puts back the saved quiz's music folder, players and statistics. Its songs come back
# with the song list, see SongLoader.load_song_list(), and its characters' pictures once
# the characters are loaded.
func _continue_saved_quiz(saved: QuizSave) -> void:
if saved == null:
Playlist.clear_song_files()
return
_restoring = true
if !saved.music_folder_path.is_empty():
var folder: MusicFolder = MusicFolder.scan(saved.music_folder_path)
if folder.kind == MusicFolder.Kind.EMPTY:
GameLog.warning("The saved quiz's music folder is gone: ", saved.music_folder_path)
saved.drop_songs()
else:
folder.restore_progress(saved.music_folder_progress)
Settings.music_folder = folder
elif saved.server != Settings.default_path:
# Another server has another playlist
saved.drop_songs()
Playlist.clear_song_files(saved.song_files())
for player: PlayerObject in saved.players:
Settings.player_array.append(player)
_on_player_added(player)
if saved.characters_dir != Settings.characters_dir():
for player: PlayerObject in saved.players:
player.clear_character()
players_panel.reload()
if saved.stats_paused_by_win:
stats_paused_by_win = true
Settings.add_to_stats = false
# The players' characters are saved by name; their pictures come from the characters
func _load_saved_characters(saved: QuizSave) -> void:
if saved == null:
return
for player: PlayerObject in saved.players:
var character_name: String = player.character_name
if character_name.is_empty():
continue
character_select_popup.source.fetch_image(character_name, func(picture: Image) -> void:
# Another character may have been picked meanwhile
if player.character_name == character_name:
player.character = ImageTexture.create_from_image(picture)
players_panel.reload())
func _on_song_list_loaded() -> void:
if _restoring:
_finish_restoring()
update_song_list()
# The saved songs are back: the song that was playing is ready to play again, and songs
# whose files are gone are downloaded again
func _finish_restoring() -> void:
_restoring = false
if !Settings.is_offline():
for i: int in Playlist.song_object_array.size():
var song_object: SongObject = Playlist.song_object_array[i]
var needed: bool = !song_object.has_played || i == Playlist.currently_playing_song
if needed && Playlist.get_song(i) == null:
MusicServerClient.fetch_song(song_object.song_number,
_on_saved_song_downloaded.bind(i))
if Playlist.currently_playing_song >= 0:
music_player.load_song_object(Playlist.currently_playing_song)
if Playlist.get_preloaded_songs() > 0:
song_controls.set_next_enabled(true)
func _on_saved_song_downloaded(body: PackedByteArray, song_no: int) -> void:
if song_no >= Playlist.song_object_array.size():
return
Playlist.add_song_to_song_object(song_no, body)
if song_no == Playlist.currently_playing_song && !music_player.audio_player.playing:
music_player.load_song_object(song_no)
save_soon()
func _on_saved_songs_dropped() -> void:
for player: PlayerObject in Settings.player_array:
player.reset_points()
players_panel.reload()
# Characters picked before are not among the new ones, so everyone picks again
func _on_character_source_changed() -> void:
for player: PlayerObject in Settings.player_array:
player.clear_character()
players_panel.reload()
save_soon()
func song_object_clicked(song_no: int) -> void:
@@ -204,6 +326,7 @@ func reset_points() -> void:
if stats_paused_by_win:
Settings.add_to_stats = true
stats_paused_by_win = false
save_soon()
# Each new player's points reach the music player, the log and the winner popup.
@@ -214,6 +337,7 @@ func _on_player_added(new_player_object: PlayerObject) -> void:
new_player_object.point_given_sound.connect(_on_make_point_given_sound)
new_player_object.player_point_given.connect(_on_point_given.bind(new_player_object))
new_player_object.player_point_taken.connect(_on_point_taken.bind(new_player_object))
save_soon()
func _on_point_given(player_object: PlayerObject) -> void:
@@ -259,6 +383,7 @@ func _on_player_won(player_object: PlayerObject) -> void:
if Settings.add_to_stats:
Settings.add_to_stats = false
stats_paused_by_win = true
save_soon()
func _on_player_removed(player_object: PlayerObject) -> void:
@@ -273,6 +398,7 @@ func _on_change_character_requested(new_player: PlayerRow, player_name: String)
character_select_popup.show_grid(player_name)
func _on_new_character_selected(character_texture: ImageTexture) -> void:
func _on_new_character_selected(character_texture: ImageTexture, character_name: String) -> void:
GameLog.debug("_on_new_character_selected")
current_player_row.new_character_selected(character_texture)
current_player_row.new_character_selected(character_texture, character_name)
save_soon()
+57 -11
View File
@@ -15,22 +15,39 @@ signal song_list_loaded
## Emitted when a new song has been downloaded and added to the Playlist.
signal song_added
## Emitted when the songs of a saved quiz could not be continued, because the server's
## playlist no longer has them, e.g. after a sync. The quiz starts over.
signal saved_songs_dropped
## Seconds to wait before retrying a download step that failed.
var retry_seconds: float = 5.0
## Loads the songs already in the server playlist, then starts preloading new ones.
## Preloading waits for the list, because loading the list clears the playlist.
func load_song_list() -> void:
##
## With [param saved], the songs of a saved quiz are used where they match the server's
## playlist, so who got points and what was answered or downloaded ahead is kept. When
## the server's playlist no longer has them, they are dropped and
## [signal saved_songs_dropped] is emitted.
func load_song_list(saved: QuizSave = null) -> void:
GameLog.debug("load_song_list")
if Settings.is_offline():
_start_folder_playlist()
_start_folder_playlist(saved)
return
var song_list_fetched: Callable = func song_list_fetched(songs: Array[SongInfo]) -> void:
if Settings.is_offline():
return
Playlist.clear_song_list()
for info: SongInfo in songs:
var continued: int = 0
if saved != null && !saved.songs.is_empty():
if saved_songs_match(saved.songs, songs):
continued = saved.songs.size()
_continue_songs(saved)
else:
GameLog.warning("The server's playlist changed, so the saved quiz starts over")
saved_songs_dropped.emit()
for info: SongInfo in songs.slice(continued):
var new_song_object: SongObject = SongObject.new()
new_song_object.song_number = info.song_no
new_song_object.soundtrack_title = info.soundtrack
@@ -38,12 +55,38 @@ func load_song_list() -> void:
new_song_object.is_answered = true
new_song_object.has_played = true
Playlist.add_to_playlist(new_song_object)
Playlist.update_last_played_song()
# Songs already on the server were played before, so the game starts after them
Playlist.game_start_song = Playlist.song_object_array.size()
if continued == 0:
Playlist.update_last_played_song()
# Songs already on the server were played before, so the game starts after them
Playlist.game_start_song = Playlist.song_object_array.size()
song_list_loaded.emit()
fetch_song_if_idle()
MusicServerClient.fetch_song_list(song_list_fetched, _retry_later.bind(load_song_list))
# Songs downloaded ahead before a restart already fill some of the cache
fill_cache()
MusicServerClient.fetch_song_list(song_list_fetched,
_retry_later.bind(load_song_list.bind(saved)))
## Whether [param saved_songs] are the first songs of the server's playlist [param songs]:
## the same numbers, soundtracks and titles in the same order.
static func saved_songs_match(saved_songs: Array[SongObject], songs: Array[SongInfo]) -> bool:
if saved_songs.size() > songs.size():
return false
for i: int in saved_songs.size():
if saved_songs[i].song_number != songs[i].song_no \
|| saved_songs[i].soundtrack_title != songs[i].soundtrack \
|| saved_songs[i].song_title != songs[i].song:
return false
return true
# Puts the saved songs in the playlist, where the quiz was
func _continue_songs(saved: QuizSave) -> void:
for song_object: SongObject in saved.songs:
Playlist.add_to_playlist(song_object)
Playlist.currently_playing_song = saved.currently_playing_song
Playlist.latest_played_song = saved.latest_played_song
Playlist.game_start_song = saved.game_start_song
Playlist.first_song_played = saved.first_song_played
## Starts downloading one more song, which keeps downloading until the cache is full.
@@ -114,14 +157,17 @@ func _retry_later(step: Callable) -> void:
step.call())
# A music folder has no playlist on a server, so the game starts with an empty one
func _start_folder_playlist() -> void:
# A music folder has no playlist on a server, so the game starts with an empty one, or
# with the saved quiz's songs
func _start_folder_playlist(saved: QuizSave = null) -> void:
Playlist.clear_song_list()
Playlist.update_last_played_song()
Playlist.game_start_song = 0
if saved != null && !saved.songs.is_empty():
_continue_songs(saved)
Playlist.is_song_downloading = false
song_list_loaded.emit()
fetch_song_if_idle()
fill_cache()
# Picks songs from the music folder until enough are ready. A prepared quiz runs out