"Version X is available" in the top bar is now a button that opens an Update popup: - Update and restart gets the download link from the server (the redirect is not followed, as the Gitea link needs no token), downloads the new game next to the running one with a progress bar, checks it (complete, and an MZ or ELF file), swaps it in and restarts. The quiz is saved first and continues. On Windows the running .exe is renamed to .old, put back if the swap fails, and deleted at the next start. - Download in browser opens the .exe, the Linux package, or the release page on other platforms. - From the editor, an unwritable folder or another platform, only the browser download is offered, with the reason. The message now fits between the tabs and the buttons in every theme; in Nord and Christmas it ran into Sync. The macOS export preset is removed, and the unused download_client_* functions are replaced by fetch_client_download_url. Closes #13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
406 lines
15 KiB
GDScript
406 lines
15 KiB
GDScript
class_name Quiz
|
|
extends Control
|
|
## The quiz: the song that is playing, the playlist, the players and their points, and
|
|
## the popups opened from here (the quiz settings card, character select and winner).
|
|
##
|
|
## 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.
|
|
|
|
signal server_changed
|
|
|
|
## Emitted when Search is pressed. The Search popup is in the main window.
|
|
signal search_pressed
|
|
|
|
## Emitted with a line for the game log, e.g. when a player gets a point.
|
|
signal event_logged(text: String)
|
|
|
|
var current_player_row: PlayerRow
|
|
|
|
## True while statistics are off because a player won, until points are reset.
|
|
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
|
|
@onready var toolbar: QuizToolbar = $QuizToolbar
|
|
@onready var quiz_settings_card: QuizSettingsCard = $QuizSettingsCard
|
|
@onready var quiz_settings_button: Button = $QuizSettingsButton
|
|
@onready var winner_popup: WinnerPopup = %WinnerPopup
|
|
@onready var song_controls: SongControls = $SongControls
|
|
@onready var song_loader: SongLoader = $SongLoader
|
|
@onready var music_list_panel: MusicListPanel = $MusicListPanel
|
|
@onready var character_select_popup: CharacterSelectPopup = %CharacterSelectPopup
|
|
@onready var music_player: MusicPlayer = $MusicPlayer
|
|
|
|
|
|
func _ready() -> void:
|
|
popup_backdrop = PopupBackdrop.add_to(self, _popups())
|
|
song_controls.next_pressed.connect(play_next_song)
|
|
song_controls.show_answer_pressed.connect(show_answer_pressed)
|
|
song_controls.show_winner_pressed.connect(show_final_winners)
|
|
toolbar.sound_test_pressed.connect(music_player.get_sound_test_song)
|
|
toolbar.search_pressed.connect(search_pressed.emit)
|
|
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(_on_song_list_loaded)
|
|
song_loader.song_added.connect(_on_song_added)
|
|
|
|
character_select_popup.character_selected.connect(_on_new_character_selected)
|
|
music_list_panel.song_clicked.connect(song_object_clicked)
|
|
players_panel.player_added.connect(_on_player_added)
|
|
players_panel.player_removed.connect(_on_player_removed)
|
|
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(saved)
|
|
character_select_popup.load_characters()
|
|
_load_saved_characters(saved)
|
|
|
|
|
|
func _input(event: InputEvent) -> void:
|
|
if event is InputEventKey and event.pressed:
|
|
if event.alt_pressed && event.keycode == KEY_UP:
|
|
GameLog.debug("Alt + UP pressed")
|
|
music_player.change_volume(0.05)
|
|
if event.alt_pressed && event.keycode == KEY_DOWN:
|
|
GameLog.debug("Alt + DOWN pressed")
|
|
music_player.change_volume(-0.05)
|
|
if event.alt_pressed && event.ctrl_pressed && event.keycode == KEY_LEFT:
|
|
GameLog.debug("Ctrl + Alt + LEFT pressed")
|
|
music_player.restart()
|
|
elif event.alt_pressed && event.keycode == KEY_LEFT:
|
|
GameLog.debug("Alt + LEFT pressed")
|
|
music_player.seek(-5)
|
|
if event.alt_pressed && event.keycode == KEY_RIGHT:
|
|
GameLog.debug("Alt + RIGHT pressed")
|
|
music_player.seek(5)
|
|
|
|
|
|
## Disables the buttons that use the song list while the server syncs it.
|
|
func lock_for_sync() -> void:
|
|
toolbar.set_locked(true)
|
|
song_controls.set_locked(true)
|
|
|
|
|
|
## Reloads from the server after a sync and starts a new game.
|
|
func on_sync_finished() -> void:
|
|
refresh_from_server()
|
|
toolbar.set_locked(false)
|
|
song_controls.set_locked(false)
|
|
reset_points()
|
|
|
|
|
|
## Reloads the soundtracks, playlist and characters from the server.
|
|
func refresh_from_server() -> void:
|
|
GameLog.debug("refresh_from_server")
|
|
inspiration_list.load_soundtracks()
|
|
reset_playlist()
|
|
character_select_popup.load_characters()
|
|
|
|
|
|
## Reloads everything from the server picked in the settings and starts a new game.
|
|
func on_server_changed() -> void:
|
|
GameLog.debug("on_server_changed")
|
|
refresh_from_server()
|
|
reset_points()
|
|
server_changed.emit()
|
|
|
|
|
|
func show_answer_pressed() -> void:
|
|
GameLog.debug("show_answer_pressed")
|
|
Playlist.song_is_answered()
|
|
update_song_list()
|
|
|
|
|
|
func play_next_song() -> void:
|
|
GameLog.debug("play_next_song ", Playlist.latest_played_song)
|
|
# Every song of a prepared quiz has played, e.g. when songs play on by themselves
|
|
if Settings.is_prepared_quiz() && Playlist.first_song_played \
|
|
&& Playlist.get_preloaded_songs() == 0:
|
|
song_controls.show_winner_button()
|
|
return
|
|
if Playlist.first_song_played:
|
|
Playlist.set_latest_played_song()
|
|
Playlist.first_song_played = true
|
|
# The song counts as played once it starts, which can take a moment if another song
|
|
# is stopping, so the next song is picked after that
|
|
await music_player.play_song_object(Playlist.latest_played_song)
|
|
song_loader.fetch_song_if_idle()
|
|
if !(Playlist.get_preloaded_songs() > 0):
|
|
if Settings.is_prepared_quiz():
|
|
song_controls.show_winner_button()
|
|
else:
|
|
song_controls.set_next_enabled(false)
|
|
|
|
|
|
# The quiz settings card is left out: it does not dim the quiz
|
|
func _popups() -> Array[BasePopup]:
|
|
return [character_select_popup, winner_popup]
|
|
|
|
|
|
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:
|
|
GameLog.debug("update_song_list")
|
|
if Playlist.currently_playing_song >= 0:
|
|
now_playing.show_song(Playlist.get_current_soundtrack_title(),
|
|
Playlist.get_current_song_title(), Playlist.get_current_soundtrack_uuid())
|
|
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()
|
|
|
|
|
|
## Saves the quiz at once, e.g. before the game restarts with a new version.
|
|
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:
|
|
GameLog.debug("Clicked Song Object: ", Playlist.song_object_array[song_no].to_string())
|
|
if !Settings.character_select_open:
|
|
if Playlist.get_song(song_no) == null:
|
|
if Settings.is_offline():
|
|
GameLog.warning("The song's file is gone: ",
|
|
Playlist.song_object_array[song_no].song_path)
|
|
return
|
|
GameLog.debug("has no song, need to download")
|
|
var song_downloaded: Callable = func song_downloaded(body: PackedByteArray) -> void:
|
|
Playlist.add_song_to_song_object(song_no, body)
|
|
music_player.play_song_object(song_no)
|
|
MusicServerClient.fetch_song(song_no, song_downloaded)
|
|
else:
|
|
GameLog.debug("song already downloaded")
|
|
music_player.play_song_object(song_no)
|
|
|
|
|
|
func reset_playlist() -> void:
|
|
GameLog.debug("reset_playlist")
|
|
var playlist_reset_done: Callable = func playlist_reset_done() -> void:
|
|
Playlist.reset_playlist()
|
|
song_loader.load_song_list()
|
|
if Settings.is_offline():
|
|
Settings.music_folder.restart()
|
|
playlist_reset_done.call()
|
|
return
|
|
MusicServerClient.reset_playlist(playlist_reset_done)
|
|
|
|
|
|
func reset_points() -> void:
|
|
GameLog.debug("reset_points")
|
|
for player_objects: PlayerObject in Settings.player_array:
|
|
player_objects.reset_points()
|
|
players_panel.reload()
|
|
# The song playing now belongs to the game that just ended
|
|
Playlist.clear_points()
|
|
Playlist.game_start_song = Playlist.currently_playing_song + 1
|
|
music_list_panel.refresh()
|
|
# A new game starts, so turn statistics back on if winning turned them off
|
|
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.
|
|
func _on_player_added(new_player_object: PlayerObject) -> void:
|
|
new_player_object.first_point_triggered.connect(music_player._on_point_triggered.bind("first"))
|
|
new_player_object.match_point_triggered.connect(music_player._on_point_triggered.bind("match"))
|
|
new_player_object.winner_triggered.connect(_on_player_won.bind(new_player_object))
|
|
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:
|
|
GameLog.debug("_on_point_given")
|
|
event_logged.emit(player_object.player_name + " got a point")
|
|
if Playlist.currently_playing_song >= 0:
|
|
Playlist.add_point(player_object.id)
|
|
update_song_list()
|
|
|
|
|
|
func _on_make_point_given_sound() -> void:
|
|
music_player.play_sound_effect(preload("res://sounds/itemopen.wav"))
|
|
|
|
|
|
func _on_point_taken(player_object: PlayerObject) -> void:
|
|
GameLog.debug("_on_point_taken")
|
|
event_logged.emit(player_object.player_name + " lost a point")
|
|
music_player.play_sound_effect(preload("res://sounds/itemequip.wav"))
|
|
if Playlist.currently_playing_song >= 0:
|
|
Playlist.remove_point(player_object.id)
|
|
update_song_list()
|
|
|
|
|
|
## Shows the players with the most points as the winners, at the end of a prepared quiz.
|
|
func show_final_winners() -> void:
|
|
GameLog.debug("show_final_winners")
|
|
var top_score: int = 0
|
|
for player: PlayerObject in Settings.player_array:
|
|
top_score = maxi(top_score, player.player_score)
|
|
var winners: Array[PlayerObject] = []
|
|
for player: PlayerObject in Settings.player_array:
|
|
if player.player_score == top_score:
|
|
winners.append(player)
|
|
winner_popup.show_winners(winners)
|
|
music_player.play_song(preload("res://sounds/winning.mp3"))
|
|
|
|
|
|
func _on_player_won(player_object: PlayerObject) -> void:
|
|
GameLog.debug("_on_player_won")
|
|
winner_popup.show_winner(player_object)
|
|
music_player.play_song(preload("res://sounds/winning.mp3"))
|
|
# Songs played after the game is won don't count towards the statistics
|
|
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:
|
|
Playlist.remove_player_from_list(player_object.id)
|
|
update_song_list()
|
|
|
|
|
|
func _on_change_character_requested(new_player: PlayerRow, player_name: String) -> void:
|
|
GameLog.debug("_on_player_change_character_clicked")
|
|
current_player_row = new_player
|
|
GameLog.debug("Change character for: " + player_name)
|
|
character_select_popup.show_grid(player_name)
|
|
|
|
|
|
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, character_name)
|
|
save_soon()
|