diff --git a/common/folder_characters.gd b/common/folder_characters.gd new file mode 100644 index 0000000..40be1ab --- /dev/null +++ b/common/folder_characters.gd @@ -0,0 +1,47 @@ +class_name FolderCharacters +extends RefCounted +## Characters read from a folder on this computer instead of the server: every PNG, JPG +## or WebP picture in it is a character, named by its file name. +## +## Has the same two calls as [CharacterCache], so the character select popup uses either. + +## The file endings of the pictures that are characters. +const PICTURE_EXTENSIONS: Array[String] = ["png", "jpg", "jpeg", "webp"] + +## The folder the pictures are in. +var path: String = "" + + +func _init(folder_path: String) -> void: + path = folder_path + + +## The file names of the pictures in [param folder], in natural order. Hidden files and +## other files are left out. +static func picture_names(folder: String) -> Array[String]: + var names: Array[String] = [] + for file_name: String in DirAccess.get_files_at(folder): + if !file_name.begins_with(".") \ + && PICTURE_EXTENSIONS.has(file_name.get_extension().to_lower()): + names.append(file_name) + names.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0) + return names + + +## Calls [param on_done] with the names of the characters as an [code]Array[String][/code]. +func load_list(on_done: Callable) -> void: + on_done.call(picture_names(path)) + + +## Calls [param on_done] with the picture of [param character_name] as an [Image]. Nothing +## is called when the picture is gone or can't be read. +func fetch_image(character_name: String, on_done: Callable) -> void: + var file: String = path.path_join(character_name.validate_filename()) + if !FileAccess.file_exists(file): + GameLog.warning("The picture of ", character_name, " is not in ", path) + return + var picture: Image = CharacterCard.image_from_bytes(FileAccess.get_file_as_bytes(file)) + if picture == null: + GameLog.warning("Could not read the picture ", file) + return + on_done.call(picture) diff --git a/common/folder_characters.gd.uid b/common/folder_characters.gd.uid new file mode 100644 index 0000000..50ddc3e --- /dev/null +++ b/common/folder_characters.gd.uid @@ -0,0 +1 @@ +uid://bc2nkmd162ibl diff --git a/data/music_folder.gd b/data/music_folder.gd index 87f277d..5d16ddd 100644 --- a/data/music_folder.gd +++ b/data/music_folder.gd @@ -8,19 +8,25 @@ extends RefCounted ## 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. +## out. A subfolder called [code]characters[/code] holds the quiz's own characters +## instead of songs, see [member characters_path]. 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 subfolder with the quiz's own characters. Any case, e.g. "Characters", works. +const CHARACTERS_FOLDER: String = "characters" ## 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 +## The folder's [constant CHARACTERS_FOLDER] subfolder when it has pictures, so a themed +## quiz can bring its own characters. Empty when there is none. +var characters_path: String = "" # Soundtrack folder: soundtrack name -> paths of its songs var _soundtracks: Dictionary[String, PackedStringArray] = {} @@ -35,7 +41,14 @@ var _quiz_next: int = 0 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)) + var subfolders: PackedStringArray = [] + for subfolder: String in _visible(DirAccess.get_directories_at(folder.path)): + if subfolder.to_lower() == CHARACTERS_FOLDER: + var characters: String = folder.path.path_join(subfolder) + if !FolderCharacters.picture_names(characters).is_empty(): + folder.characters_path = characters + else: + subfolders.append(subfolder) if !subfolders.is_empty(): for subfolder: String in subfolders: var songs: PackedStringArray = _songs_in(folder.path.path_join(subfolder), true) @@ -86,6 +99,35 @@ func restart() -> void: _quiz_next = 0 +## How far a prepared quiz has come, to continue it after a restart with +## [method restore_progress]. +func progress() -> Dictionary: + return {"play_order": play_order, "queue": Array(_quiz_queue), "next": _quiz_next} + + +## Continues a prepared quiz from [param saved], as given by [method progress]. Songs +## that are no longer in the folder are left out. Returns false, changing nothing, when +## [param saved] does not fit this folder. +func restore_progress(saved: Dictionary) -> bool: + if kind != Kind.PREPARED_QUIZ || !(saved.get("queue") is Array) \ + || !(saved.get("next") is float || saved.get("next") is int): + return false + var queue: PackedStringArray = [] + var next: int = int(saved.next) + var known: Dictionary = {} + for song_path: String in _quiz_songs: + known[song_path] = true + for i: int in saved.queue.size(): + if known.has(str(saved.queue[i])): + queue.append(str(saved.queue[i])) + elif i < int(saved.next): + next -= 1 + play_order = int(saved.get("play_order", play_order)) as PlayOrder + _quiz_queue = queue + _quiz_next = clampi(next, 0, queue.size()) + return true + + ## 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: diff --git a/data/player_object.gd b/data/player_object.gd index 77cce1b..6b44dfe 100644 --- a/data/player_object.gd +++ b/data/player_object.gd @@ -21,6 +21,9 @@ var id: String = _generate_uuid() var is_first_point: bool = true var character: Texture = load(NO_CHARACTER_PATH) +## The name of the picked character, its picture's file name, or empty when none is +## picked. Saved with the quiz, so the picture can be found again after a restart. +var character_name: String = "" func _init(new_name: String) -> void: @@ -28,6 +31,39 @@ func _init(new_name: String) -> void: player_score = 0 +## Shows [param texture], the character called [param new_name], for the player. +func set_character(texture: Texture, new_name: String) -> void: + character = texture + character_name = new_name + + +## Goes back to no character, e.g. when the characters come from somewhere else. +func clear_character() -> void: + character = load(NO_CHARACTER_PATH) + character_name = "" + + +## The player as a Dictionary that can be written as JSON, for [QuizSave]. +func to_dict() -> Dictionary: + return { + "id": id, "name": player_name, "score": player_score, + "is_first_point": is_first_point, "character": character_name, + } + + +## A player made from [param data], as given by [method to_dict], or null when +## [param data] is not a player. The character's picture is not loaded here. +static func from_dict(data: Variant) -> PlayerObject: + if !(data is Dictionary) || !(data.get("name") is String) || !(data.get("id") is String): + return null + var player: PlayerObject = PlayerObject.new(data.name) + player.id = data.id + player.player_score = maxi(int(data.get("score", 0)), 0) + player.is_first_point = bool(data.get("is_first_point", player.player_score == 0)) + player.character_name = str(data.get("character", "")) + return player + + ## Returns a random version 4 UUID, e.g. "3f2b8c1e-9a4d-4e7f-b2c6-5d8e1f0a7b93". static func _generate_uuid() -> String: var bytes: PackedByteArray = Crypto.new().generate_random_bytes(16) diff --git a/data/playlist.gd b/data/playlist.gd index 9060ad9..b419280 100644 --- a/data/playlist.gd +++ b/data/playlist.gd @@ -5,8 +5,9 @@ extends Node ## Registered as the [code]Playlist[/code] autoload. ## ## Downloaded songs are kept on disk in the [code]songs[/code] folder next to the -## settings, not in memory. The folder only holds the current playlist: it is emptied when -## the game starts and when the playlist is reset. +## settings, not in memory. The folder only holds the current playlist: when the game +## starts, songs the saved quiz does not use are deleted (see [QuizSave]), and the folder +## is emptied when the playlist is reset. const SONGS_FOLDER_NAME: String = "songs" @@ -25,10 +26,6 @@ var game_start_song: int = 0 var _song_files_written: int = 0 -func _ready() -> void: - clear_song_files() - - ## The folder downloaded songs are kept in. static func songs_folder() -> String: return Settings.config_dir().path_join(SONGS_FOLDER_NAME) @@ -152,7 +149,11 @@ func add_song_to_song_object(song_no: int, mp3: PackedByteArray) -> void: ## written, the song is kept in memory instead. func store_song(song_object: SongObject, mp3: PackedByteArray) -> void: DirAccess.make_dir_recursive_absolute(songs_folder()) + # Songs kept from a saved quiz already have files, which must not be written over var path: String = songs_folder().path_join("song_%d.mp3" % _song_files_written) + while FileAccess.file_exists(path): + _song_files_written += 1 + path = songs_folder().path_join("song_%d.mp3" % _song_files_written) _song_files_written += 1 var file: FileAccess = FileAccess.open(path, FileAccess.WRITE) if file == null: @@ -169,12 +170,14 @@ func store_song(song_object: SongObject, mp3: PackedByteArray) -> void: song_object.song_path = path -## Deletes the files of the songs downloaded so far. -func clear_song_files() -> void: +## Deletes the files of the songs downloaded so far, except those in [param keep]. +func clear_song_files(keep: PackedStringArray = []) -> void: if !DirAccess.dir_exists_absolute(songs_folder()): return for file_name: String in DirAccess.get_files_at(songs_folder()): - DirAccess.remove_absolute(songs_folder().path_join(file_name)) + var path: String = songs_folder().path_join(file_name) + if !keep.has(path): + DirAccess.remove_absolute(path) func song_has_played(song_no: int = latest_played_song) -> void: diff --git a/data/quiz_save.gd b/data/quiz_save.gd new file mode 100644 index 0000000..a28a09d --- /dev/null +++ b/data/quiz_save.gd @@ -0,0 +1,174 @@ +class_name QuizSave +extends RefCounted +## The quiz going on, saved to a file after every change, so the game goes on where it +## was when it is started again: the players with their points and characters, and every +## song with who got a point for it and whether it was played, answered or is playing. +## +## Songs downloaded ahead keep their files, so they are not downloaded again. A new game +## (Reset points, a sync or another server) is saved like any other change, so the file +## always holds the quiz as it is now. +## +## Kept in [code]current_quiz.json[/code] next to the settings, or +## [code]current_quiz_debug.json[/code] in debug mode, like the saved settings. + +const FILE_NAME: String = "current_quiz.json" +const DEBUG_FILE_NAME: String = "current_quiz_debug.json" +## Files with another version are from a game that saved differently, and are ignored. +const VERSION: int = 1 + +var players: Array[PlayerObject] = [] +var songs: Array[SongObject] = [] +var currently_playing_song: int = -1 +var latest_played_song: int = 0 +var game_start_song: int = 0 +var first_song_played: bool = false +## Whether statistics were turned off because a player won, see [code]Quiz[/code]. +var stats_paused_by_win: bool = false +## The server the songs came from. Empty when they came from a music folder. +var server: String = "" +## The music folder the songs came from, or empty when they came from the server. +var music_folder_path: String = "" +## How far a prepared quiz in [member music_folder_path] has come, see +## [method MusicFolder.progress]. +var music_folder_progress: Dictionary = {} +## Where the characters came from, see [method Settings.characters_dir]. +var characters_dir: String = "" + + +## Where the quiz is saved. +static func path() -> String: + return Settings.config_dir().path_join(DEBUG_FILE_NAME if Settings.is_debug else FILE_NAME) + + +## The quiz as it is now, in [code]Settings[/code] and [code]Playlist[/code]. +static func capture(paused_by_win: bool) -> QuizSave: + var save: QuizSave = QuizSave.new() + save.players = Settings.player_array.duplicate() + save.songs = Playlist.song_object_array.duplicate() + save.currently_playing_song = Playlist.currently_playing_song + save.latest_played_song = Playlist.latest_played_song + save.game_start_song = Playlist.game_start_song + save.first_song_played = Playlist.first_song_played + save.stats_paused_by_win = paused_by_win + if Settings.is_offline(): + save.music_folder_path = Settings.music_folder.path + save.music_folder_progress = Settings.music_folder.progress() + else: + save.server = Settings.default_path + save.characters_dir = Settings.characters_dir() + return save + + +## Reads the saved quiz, or returns null when there is none or it can't be read. +static func read() -> QuizSave: + if !FileAccess.file_exists(path()): + return null + var save: QuizSave = from_dict(JSON.parse_string(FileAccess.get_file_as_string(path()))) + if save == null: + GameLog.warning("Could not read the saved quiz in ", path(), ", starting a new one") + return save + + +## Deletes the saved quiz. +static func delete() -> void: + if FileAccess.file_exists(path()): + DirAccess.remove_absolute(path()) + + +## Saves the quiz. It is written to another file first and then moved, so a crash while +## saving never leaves half a file. Returns false when it could not be saved. +func write() -> bool: + var target: String = path() + var temporary: String = target + ".tmp" + DirAccess.make_dir_recursive_absolute(target.get_base_dir()) + var file: FileAccess = FileAccess.open(temporary, FileAccess.WRITE) + if file == null: + GameLog.warning("Could not save the quiz to ", temporary, ": ", + error_string(FileAccess.get_open_error())) + return false + file.store_string(JSON.stringify(to_dict(), "\t")) + file.close() + var error: Error = DirAccess.rename_absolute(temporary, target) + if error != OK: + GameLog.warning("Could not save the quiz to ", target, ": ", error_string(error)) + return false + return true + + +## The song files the saved songs use, which must not be deleted. +func song_files() -> PackedStringArray: + var files: PackedStringArray = [] + for song_object: SongObject in songs: + if !song_object.song_path.is_empty(): + files.append(song_object.song_path) + return files + + +func to_dict() -> Dictionary: + var player_list: Array[Dictionary] = [] + for player: PlayerObject in players: + player_list.append(player.to_dict()) + var song_list: Array[Dictionary] = [] + for song_object: SongObject in songs: + song_list.append(song_object.to_dict()) + return { + "version": VERSION, + "players": player_list, + "songs": song_list, + "currently_playing_song": currently_playing_song, + "latest_played_song": latest_played_song, + "game_start_song": game_start_song, + "first_song_played": first_song_played, + "stats_paused_by_win": stats_paused_by_win, + "server": server, + "music_folder": {"path": music_folder_path, "progress": music_folder_progress}, + "characters_dir": characters_dir, + } + + +## A save made from [param data], as given by [method to_dict], or null when +## [param data] is not a saved quiz of this [constant VERSION]. Players and songs that +## can't be read are left out. +static func from_dict(data: Variant) -> QuizSave: + if !(data is Dictionary) || int(data.get("version", 0)) != VERSION \ + || !(data.get("players") is Array) || !(data.get("songs") is Array): + return null + var save: QuizSave = QuizSave.new() + for player_data: Variant in data.players: + var player: PlayerObject = PlayerObject.from_dict(player_data) + if player != null && save.players.size() < Settings.MAX_NUMBER_OF_PLAYERS: + save.players.append(player) + for song_data: Variant in data.songs: + var song_object: SongObject = SongObject.from_dict(song_data) + if song_object == null: + # The songs' places in the list are their numbers, so none can be left out + save.songs.clear() + break + save.songs.append(song_object) + save.currently_playing_song = clampi(int(data.get("currently_playing_song", -1)), -1, + save.songs.size() - 1) + save.latest_played_song = clampi(int(data.get("latest_played_song", 0)), 0, + maxi(save.songs.size() - 1, 0)) + save.game_start_song = clampi(int(data.get("game_start_song", 0)), 0, save.songs.size()) + save.first_song_played = bool(data.get("first_song_played", false)) + save.stats_paused_by_win = bool(data.get("stats_paused_by_win", false)) + save.server = str(data.get("server", "")) + var folder: Variant = data.get("music_folder", {}) + if folder is Dictionary: + save.music_folder_path = str(folder.get("path", "")) + if folder.get("progress") is Dictionary: + save.music_folder_progress = folder.progress + save.characters_dir = str(data.get("characters_dir", "")) + return save + + +## Forgets the songs, e.g. when the server's playlist no longer has them. The players are +## kept, without points. +func drop_songs() -> void: + songs.clear() + currently_playing_song = -1 + latest_played_song = 0 + game_start_song = 0 + first_song_played = false + for player: PlayerObject in players: + player.reset_points() diff --git a/data/quiz_save.gd.uid b/data/quiz_save.gd.uid new file mode 100644 index 0000000..7889d25 --- /dev/null +++ b/data/quiz_save.gd.uid @@ -0,0 +1 @@ +uid://cfabuhf854ukp diff --git a/data/settings.gd b/data/settings.gd index d7ff19e..82e755b 100644 --- a/data/settings.gd +++ b/data/settings.gd @@ -88,6 +88,9 @@ 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 folder characters are read from instead of the server, picked in the settings, or +## empty. Kept for this session only. See [method characters_dir]. +var character_folder: String = "" ## The game version, set in Project Settings > Application > Config > Version. ## Releases are made from it, see RELEASING.md. @@ -233,6 +236,16 @@ func is_prepared_quiz() -> bool: return music_folder != null && music_folder.kind == MusicFolder.Kind.PREPARED_QUIZ +## The folder the characters come from, or empty for the server's: the folder picked in +## the settings, otherwise the [code]characters[/code] folder of the music folder played. +func characters_dir() -> String: + if !character_folder.is_empty(): + return character_folder + if music_folder != null: + return music_folder.characters_path + return "" + + func delete_children(node: Node) -> void: for n: Node in node.get_children(): node.remove_child(n) diff --git a/data/song_object.gd b/data/song_object.gd index 3bf00d6..b901617 100644 --- a/data/song_object.gd +++ b/data/song_object.gd @@ -61,6 +61,35 @@ func remove_point(player_id: String) -> void: players_given_point.erase(player_id) +## The song as a Dictionary that can be written as JSON, for [QuizSave]. A song kept only +## in memory is saved without its file. +func to_dict() -> Dictionary: + return { + "number": song_number, "soundtrack": soundtrack_title, "song": song_title, + "points": players_given_point.duplicate(), "answered": is_answered, + "played": has_played, "playing": is_playing, "path": song_path, + } + + +## A song made from [param data], as given by [method to_dict], or null when [param data] +## is not a song. +static func from_dict(data: Variant) -> SongObject: + if !(data is Dictionary) || !(data.get("number") is float || data.get("number") is int): + return null + var song_object: SongObject = SongObject.new() + song_object.song_number = int(data.number) + song_object.soundtrack_title = str(data.get("soundtrack", "")) + song_object.song_title = str(data.get("song", "")) + if data.get("points") is Array: + for player_id: Variant in data.points: + song_object.add_point(str(player_id)) + song_object.is_answered = bool(data.get("answered", false)) + song_object.has_played = bool(data.get("played", false)) + song_object.is_playing = bool(data.get("playing", false)) + song_object.song_path = str(data.get("path", "")) + return song_object + + func _to_string() -> String: var format_string: String = "song_number: %d | soundtrack_title: %s | song_title: %s" \ + " | is_answered: %s | has_played: %s | is_playing: %s" diff --git a/main/main_window.gd b/main/main_window.gd index 1e020d1..5022dd7 100644 --- a/main/main_window.gd +++ b/main/main_window.gd @@ -33,6 +33,7 @@ func _ready() -> void: top_bar.qr_pressed.connect(show_qr) top_bar.settings_pressed.connect(settings_popup.open) settings_popup.server_changed.connect(quiz.on_server_changed) + settings_popup.characters_changed.connect(quiz.character_select_popup.load_characters) settings_popup.visibility_changed.connect(_on_settings_popup_visibility_changed) sync_popup.sync_started.connect(_on_sync_started) @@ -42,6 +43,8 @@ func _ready() -> void: quiz.event_logged.connect(log_popup.add_log_row) statistics_view.soundtrack_selected.connect(soundtrack_stats_popup.show_soundtrack) + # A saved quiz may have been played from a music folder + top_bar.update_offline_state() search_popup.get_list_of_soundtracks() show_about_if_new_version() diff --git a/player/music_player.gd b/player/music_player.gd index f619e32..2ed9445 100644 --- a/player/music_player.gd +++ b/player/music_player.gd @@ -188,12 +188,32 @@ func play_song(song: AudioStream) -> void: music_time_label.text = format_text(progress_slider.value, stream.get_length()) +## Puts song [param song_object_no] in the player without playing it, e.g. the song that +## was playing when the game was closed. Play starts it from the beginning. +func load_song_object(song_object_no: int) -> void: + var song: AudioStream = Playlist.get_song(song_object_no) + if song == null: + return + audio_player.stream = song + stream = song + song_finished = true + _show_playing(false) + progress_slider.value = 0 + progress_slider.max_value = round(stream.get_length()) + progress_slider.tick_count = 0 if Settings.hide_ticks else round(stream.get_length() / 60) + music_time_label.text = format_text(progress_slider.value, stream.get_length()) + + func play_song_object(song_object_no: int) -> void: GameLog.debug("play_song_object") + var song: AudioStream = Playlist.get_song(song_object_no) + if song == null: + GameLog.warning("Song ", song_object_no, " has no file to play") + return if audio_player.is_playing(): audio_player.stop() await get_tree().create_timer(0.5).timeout - audio_player.stream = Playlist.get_song(song_object_no) + audio_player.stream = song sound_player.stop() audio_player.play() song_finished = false diff --git a/popups/SettingsPopup.tscn b/popups/SettingsPopup.tscn index b526af9..aa1120d 100644 --- a/popups/SettingsPopup.tscn +++ b/popups/SettingsPopup.tscn @@ -106,6 +106,43 @@ popup/item_0/id = 0 popup/item_1/text = "At random" popup/item_1/id = 1 +[node name="CharactersRow" type="HBoxContainer" parent="SettingsPanel/VBoxContainer"] +layout_mode = 2 + +[node name="Title" type="Label" parent="SettingsPanel/VBoxContainer/CharactersRow"] +layout_mode = 2 +theme_type_variation = &"SettingsRowLabel" +text = "Characters" + +[node name="CharactersLabel" type="Label" parent="SettingsPanel/VBoxContainer/CharactersRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 1 +text = "From the server" +text_overrun_behavior = 3 + +[node name="ChooseCharactersButton" type="Button" parent="SettingsPanel/VBoxContainer/CharactersRow"] +unique_name_in_owner = true +layout_mode = 2 +focus_mode = 0 +text = "Choose…" + +[node name="UseServerCharactersButton" type="Button" parent="SettingsPanel/VBoxContainer/CharactersRow"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +focus_mode = 0 +text = "Use server" + +[node name="CharactersErrorLabel" type="Label" parent="SettingsPanel/VBoxContainer"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +theme_type_variation = &"HighlightMedium" +text = "No pictures found" +text_overrun_behavior = 3 + [node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/VBoxContainer" unique_id=400304684] unique_name_in_owner = true layout_mode = 2 @@ -182,3 +219,13 @@ ok_button_text = "Select Current Folder" file_mode = 2 access = 2 use_native_dialog = true + +[node name="CharacterFolderDialog" type="FileDialog" parent="."] +unique_name_in_owner = true +title = "Choose a folder of characters" +initial_position = 2 +size = Vector2i(800, 500) +ok_button_text = "Select Current Folder" +file_mode = 2 +access = 2 +use_native_dialog = true diff --git a/popups/character_card.gd b/popups/character_card.gd index 096a737..b903a56 100644 --- a/popups/character_card.gd +++ b/popups/character_card.gd @@ -3,8 +3,11 @@ extends Panel ## One character in the character select popup: its picture and name. Clicking it picks ## the character. -## Emitted with the character's picture when the card is clicked. -signal new_character_selected(img_texture: ImageTexture) +## Emitted with the character's picture and name when the card is clicked. +signal new_character_selected(img_texture: ImageTexture, character_name: String) + +## The character's name: its picture's file name, e.g. "PaperMario.png". +var character_name: String = "" @onready var character_image: TextureRect = %CharacterTextureRect @onready var character_name_label: Label = %CharacterName @@ -19,12 +22,13 @@ func _ready() -> void: ## Shows [param character_name] and its picture. [param fetch_image] is called with the ## name and a Callable that takes the picture as an [Image], e.g. ## [method CharacterCache.fetch_image]. -func set_character_name(character_name: String, fetch_image: Callable) -> void: - character_name_label.text = display_name(character_name) +func set_character_name(new_name: String, fetch_image: Callable) -> void: + character_name = new_name + character_name_label.text = display_name(new_name) tooltip_text = character_name_label.text var image_fetched: Callable = func image_fetched(picture: Image) -> void: character_image.texture = ImageTexture.create_from_image(picture) - fetch_image.call(character_name, image_fetched) + fetch_image.call(new_name, image_fetched) ## The name to show for a picture's [param file_name]: without the file extension, and @@ -58,4 +62,4 @@ func _on_gui_input(event: InputEvent) -> void: && event.button_index == MOUSE_BUTTON_LEFT && Settings.character_select_open: accept_event() GameLog.debug(character_name_label.text) - new_character_selected.emit(character_image.texture) + new_character_selected.emit(character_image.texture, character_name) diff --git a/popups/character_select_popup.gd b/popups/character_select_popup.gd index 678e00c..8427b98 100644 --- a/popups/character_select_popup.gd +++ b/popups/character_select_popup.gd @@ -1,13 +1,24 @@ class_name CharacterSelectPopup extends BasePopup +## The characters players pick from, from the server or from a folder on this computer +## (see [method Settings.characters_dir]). -## Emitted with the picture of the character that was picked. -signal character_selected(texture: ImageTexture) +## Emitted with the picture and name of the character that was picked. +signal character_selected(texture: ImageTexture, character_name: String) + +## Emitted when the characters come from somewhere else than before, e.g. a folder was +## picked. Characters picked before are not in the new set. +signal source_changed var can_select_character: bool = false var character: PackedScene = preload("res://popups/CharacterCard.tscn") ## Keeps the pictures on disk, so only changed ones are downloaded. var character_cache: CharacterCache = CharacterCache.new() +## Where the characters shown come from: [member character_cache] or a [FolderCharacters]. +var source: RefCounted = character_cache +## The folder the characters shown come from, or empty for the server. Null before the +## characters were first loaded. +var source_dir: Variant = null @onready var character_panel: Panel = %CharacterPanel @onready var title_label: Label = %TitleLabel @@ -25,18 +36,30 @@ func _on_close_timeout() -> void: close_timer.stop() +## Loads the characters from where [method Settings.characters_dir] says. Emits +## [signal source_changed] when that is somewhere else than last time. func load_characters() -> void: GameLog.debug("load_characters") + var dir: String = Settings.characters_dir() + var changed: bool = source_dir != null && source_dir != dir + source_dir = dir + source = character_cache if dir.is_empty() else FolderCharacters.new(dir) + var loading: RefCounted = source var list_loaded: Callable = func list_loaded(names: Array[String]) -> void: GameLog.debug("list_loaded") + # The source changed again before the server answered + if source != loading: + return Settings.delete_children(character_grid) for character_name: String in names: GameLog.debug("character_name ", character_name) var character_box: CharacterCard = character.instantiate() character_grid.add_child(character_box) - character_box.set_character_name(character_name, character_cache.fetch_image) + character_box.set_character_name(character_name, loading.fetch_image) character_box.new_character_selected.connect(_on_character_selected) - character_cache.load_list(list_loaded) + loading.load_list(list_loaded) + if changed: + source_changed.emit() func show_grid(player_name: String) -> void: @@ -53,12 +76,12 @@ func close() -> void: Settings.character_select_open = false -func _on_character_selected(texture: ImageTexture) -> void: +func _on_character_selected(texture: ImageTexture, character_name: String) -> void: GameLog.debug("_on_character_selected") # Not close(): character_select_open stays true until close_timer fires, so the click # that picked the character does not also select a song behind it visible = false if can_select_character: - character_selected.emit(texture) + character_selected.emit(texture, character_name) close_timer.start() can_select_character = false diff --git a/popups/settings_popup.gd b/popups/settings_popup.gd index 27f16f6..82f68da 100644 --- a/popups/settings_popup.gd +++ b/popups/settings_popup.gd @@ -7,6 +7,8 @@ extends BasePopup ## ## 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. +## A folder of characters (see [FolderCharacters]) is used the same way; without one, a +## music folder's own [code]characters[/code] folder is used, or else the server's. ## ## The quiz settings, such as the winning score, are in [QuizSettingsCard]. @@ -14,6 +16,9 @@ extends BasePopup ## order is changed. A new game starts. signal server_changed +## Emitted when a folder of characters is picked or Use server is pressed for them. +signal characters_changed + ## Space between the rows and the panel's edges. const PADDING: Vector2 = Vector2(20, 12) @@ -33,6 +38,11 @@ const PADDING: Vector2 = Vector2(20, 12) @onready var play_order_row: HBoxContainer = %PlayOrderRow @onready var play_order_button: OptionButton = %PlayOrderButton @onready var folder_dialog: FileDialog = %FolderDialog +@onready var characters_label: Label = %CharactersLabel +@onready var choose_characters_button: Button = %ChooseCharactersButton +@onready var use_server_characters_button: Button = %UseServerCharactersButton +@onready var characters_error_label: Label = %CharactersErrorLabel +@onready var character_folder_dialog: FileDialog = %CharacterFolderDialog @onready var open_config_folder_button: Button = %OpenConfigFolderButton ## Shows a folder in the system's file manager. The tests replace it, so no file manager @@ -54,6 +64,9 @@ func _ready() -> void: choose_folder_button.pressed.connect(folder_dialog.popup_centered) folder_dialog.dir_selected.connect(use_music_folder) use_server_button.pressed.connect(use_server) + choose_characters_button.pressed.connect(character_folder_dialog.popup_centered) + character_folder_dialog.dir_selected.connect(use_character_folder) + use_server_characters_button.pressed.connect(use_server_characters) open_config_folder_button.pressed.connect(open_config_folder) open_config_folder_button.tooltip_text = Settings.config_dir() play_order_button.item_selected.connect(_on_play_order_selected) @@ -104,6 +117,26 @@ func open_config_folder() -> void: show_folder.call(dir) +## Reads the characters from the folder at [param path] instead of the server. A folder +## without pictures is not used, and the popup says so. +func use_character_folder(path: String) -> void: + if FolderCharacters.picture_names(path).is_empty(): + characters_error_label.text = "No pictures found in " + path.trim_suffix("/").get_file() + characters_error_label.visible = true + _fit_to_rows() + return + Settings.character_folder = path.trim_suffix("/") + _show_music_source() + characters_changed.emit() + + +## Uses the server's characters again, or the music folder's own if it has them. +func use_server_characters() -> void: + Settings.character_folder = "" + _show_music_source() + characters_changed.emit() + + func lower_cache() -> void: Playlist.number_of_tracks_to_preload = maxi(Playlist.number_of_tracks_to_preload - 1, 1) cache_label.text = str(Playlist.number_of_tracks_to_preload) @@ -141,6 +174,7 @@ func _on_visibility_changed() -> void: add_to_database_button.set_pressed_no_signal(Settings.add_to_stats) else: folder_error_label.visible = false + characters_error_label.visible = false _fit_to_rows() @@ -162,6 +196,15 @@ func _show_music_source() -> void: folder_label.text = folder.display_name() folder_label.tooltip_text = folder.path play_order_row.visible = folder != null && folder.kind == MusicFolder.Kind.PREPARED_QUIZ + characters_error_label.visible = false + use_server_characters_button.visible = !Settings.character_folder.is_empty() + characters_label.tooltip_text = Settings.characters_dir() + if !Settings.character_folder.is_empty(): + characters_label.text = Settings.character_folder.get_file() + elif !Settings.characters_dir().is_empty(): + characters_label.text = "From " + folder.display_name() + else: + characters_label.text = "From the server" for control: BaseButton in [select_server_button, low_played_button, add_to_database_button]: control.disabled = folder != null _fit_to_rows() diff --git a/quiz/player_row.gd b/quiz/player_row.gd index 3fbfe25..47cd58d 100644 --- a/quiz/player_row.gd +++ b/quiz/player_row.gd @@ -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() diff --git a/quiz/quiz.gd b/quiz/quiz.gd index ca31f23..b5a8639 100644 --- a/quiz/quiz.gd +++ b/quiz/quiz.gd @@ -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() diff --git a/quiz/song_loader.gd b/quiz/song_loader.gd index c47cc6e..2c1a20e 100644 --- a/quiz/song_loader.gd +++ b/quiz/song_loader.gd @@ -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 diff --git a/tests/data/layout_baseline.json b/tests/data/layout_baseline.json index 7d88e0d..fb4518f 100644 --- a/tests/data/layout_baseline.json +++ b/tests/data/layout_baseline.json @@ -2061,6 +2061,15 @@ ], "visible": false }, + "SettingsPopup/CharacterFolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, "SettingsPopup/FolderDialog": { "rect": [ 0.0, @@ -2073,25 +2082,25 @@ "SettingsPopup/SettingsPanel": { "rect": [ 700.0, - 388.0, + 370.0, 520.0, - 304.0 + 339.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer": { "rect": [ 720.0, - 400.0, + 382.0, 480.0, - 280.0 + 315.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer/AddToDatabaseCheckButton": { "rect": [ 720.0, - 400.0, + 382.0, 231.0, 31.0 ], @@ -2100,7 +2109,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow": { "rect": [ 720.0, - 400.0, + 382.0, 331.0, 31.0 ], @@ -2109,7 +2118,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheLabel": { "rect": [ 720.0, - 400.0, + 382.0, 48.0, 23.0 ], @@ -2118,7 +2127,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheTitleLabel": { "rect": [ 720.0, - 400.0, + 382.0, 211.0, 31.0 ], @@ -2127,7 +2136,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/IncreaseCacheButton": { "rect": [ 720.0, - 400.0, + 382.0, 30.0, 31.0 ], @@ -2136,16 +2145,70 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/LowerCacheButton": { "rect": [ 720.0, - 400.0, + 382.0, 30.0, 31.0 ], "visible": false }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersErrorLabel": { + "rect": [ + 720.0, + 382.0, + 1.0, + 26.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow": { + "rect": [ + 720.0, + 382.0, + 183.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/CharactersLabel": { + "rect": [ + 720.0, + 382.0, + 1.0, + 23.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/ChooseCharactersButton": { + "rect": [ + 720.0, + 382.0, + 78.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/Title": { + "rect": [ + 720.0, + 382.0, + 96.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/UseServerCharactersButton": { + "rect": [ + 720.0, + 382.0, + 90.0, + 31.0 + ], + "visible": false + }, "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow": { "rect": [ 720.0, - 400.0, + 382.0, 308.0, 31.0 ], @@ -2154,7 +2217,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/OpenConfigFolderButton": { "rect": [ 720.0, - 400.0, + 382.0, 101.0, 31.0 ], @@ -2163,7 +2226,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 203.0, 31.0 ], @@ -2172,7 +2235,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderErrorLabel": { "rect": [ 720.0, - 400.0, + 382.0, 1.0, 26.0 ], @@ -2181,7 +2244,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow": { "rect": [ 720.0, - 400.0, + 382.0, 195.0, 31.0 ], @@ -2190,7 +2253,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/ChooseFolderButton": { "rect": [ 720.0, - 400.0, + 382.0, 78.0, 31.0 ], @@ -2199,7 +2262,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/FolderLabel": { "rect": [ 720.0, - 400.0, + 382.0, 1.0, 23.0 ], @@ -2208,7 +2271,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 108.0, 31.0 ], @@ -2217,7 +2280,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/UseServerButton": { "rect": [ 720.0, - 400.0, + 382.0, 90.0, 31.0 ], @@ -2226,7 +2289,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FullscreenButton": { "rect": [ 720.0, - 400.0, + 382.0, 127.0, 31.0 ], @@ -2235,7 +2298,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/Label": { "rect": [ 720.0, - 400.0, + 382.0, 97.0, 35.0 ], @@ -2244,7 +2307,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/LowPlayedCheckButton": { "rect": [ 720.0, - 400.0, + 382.0, 285.0, 31.0 ], @@ -2253,7 +2316,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow": { "rect": [ 720.0, - 400.0, + 382.0, 314.0, 31.0 ], @@ -2262,7 +2325,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/PlayOrderButton": { "rect": [ 720.0, - 400.0, + 382.0, 123.0, 31.0 ], @@ -2271,7 +2334,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 187.0, 31.0 ], @@ -2280,7 +2343,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow": { "rect": [ 720.0, - 400.0, + 382.0, 106.0, 31.0 ], @@ -2289,7 +2352,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/SelectServerButton": { "rect": [ 720.0, - 400.0, + 382.0, 40.0, 20.0 ], @@ -2298,7 +2361,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 62.0, 31.0 ], @@ -6284,6 +6347,15 @@ ], "visible": false }, + "SettingsPopup/CharacterFolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, "SettingsPopup/FolderDialog": { "rect": [ 0.0, @@ -6296,25 +6368,25 @@ "SettingsPopup/SettingsPanel": { "rect": [ 700.0, - 388.0, + 370.0, 520.0, - 304.0 + 339.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer": { "rect": [ 720.0, - 400.0, + 382.0, 480.0, - 280.0 + 315.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer/AddToDatabaseCheckButton": { "rect": [ 720.0, - 614.0, + 631.0, 480.0, 31.0 ], @@ -6323,7 +6395,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow": { "rect": [ 720.0, - 544.0, + 561.0, 480.0, 31.0 ], @@ -6332,7 +6404,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheLabel": { "rect": [ 1084.0, - 548.0, + 565.0, 48.0, 23.0 ], @@ -6341,7 +6413,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheTitleLabel": { "rect": [ 720.0, - 544.0, + 561.0, 360.0, 31.0 ], @@ -6350,7 +6422,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/IncreaseCacheButton": { "rect": [ 1170.0, - 544.0, + 561.0, 30.0, 31.0 ], @@ -6359,16 +6431,70 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/LowerCacheButton": { "rect": [ 1136.0, - 544.0, + 561.0, 30.0, 31.0 ], "visible": false }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersErrorLabel": { + "rect": [ + 720.0, + 382.0, + 1.0, + 26.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow": { + "rect": [ + 720.0, + 491.0, + 480.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/CharactersLabel": { + "rect": [ + 820.0, + 495.0, + 298.0, + 23.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/ChooseCharactersButton": { + "rect": [ + 1122.0, + 491.0, + 78.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/Title": { + "rect": [ + 720.0, + 491.0, + 96.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/UseServerCharactersButton": { + "rect": [ + 720.0, + 491.0, + 90.0, + 31.0 + ], + "visible": false + }, "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow": { "rect": [ 720.0, - 649.0, + 666.0, 480.0, 31.0 ], @@ -6377,7 +6503,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/OpenConfigFolderButton": { "rect": [ 1099.0, - 649.0, + 666.0, 101.0, 31.0 ], @@ -6386,7 +6512,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/Title": { "rect": [ 720.0, - 649.0, + 666.0, 375.0, 31.0 ], @@ -6395,7 +6521,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderErrorLabel": { "rect": [ 720.0, - 400.0, + 382.0, 1.0, 26.0 ], @@ -6404,7 +6530,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow": { "rect": [ 720.0, - 474.0, + 456.0, 480.0, 31.0 ], @@ -6413,7 +6539,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/ChooseFolderButton": { "rect": [ 1122.0, - 474.0, + 456.0, 78.0, 31.0 ], @@ -6422,7 +6548,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/FolderLabel": { "rect": [ 832.0, - 478.0, + 460.0, 286.0, 23.0 ], @@ -6431,7 +6557,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/Title": { "rect": [ 720.0, - 474.0, + 456.0, 108.0, 31.0 ], @@ -6440,7 +6566,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/UseServerButton": { "rect": [ 720.0, - 474.0, + 456.0, 90.0, 31.0 ], @@ -6449,7 +6575,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FullscreenButton": { "rect": [ 720.0, - 509.0, + 526.0, 480.0, 31.0 ], @@ -6458,7 +6584,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/Label": { "rect": [ 720.0, - 400.0, + 382.0, 480.0, 35.0 ], @@ -6467,7 +6593,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/LowPlayedCheckButton": { "rect": [ 720.0, - 579.0, + 596.0, 480.0, 31.0 ], @@ -6476,7 +6602,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow": { "rect": [ 720.0, - 400.0, + 382.0, 314.0, 31.0 ], @@ -6485,7 +6611,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/PlayOrderButton": { "rect": [ 720.0, - 400.0, + 382.0, 123.0, 31.0 ], @@ -6494,7 +6620,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 187.0, 31.0 ], @@ -6503,7 +6629,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow": { "rect": [ 720.0, - 439.0, + 421.0, 480.0, 31.0 ], @@ -6512,7 +6638,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/SelectServerButton": { "rect": [ 786.0, - 439.0, + 421.0, 414.0, 31.0 ], @@ -6521,7 +6647,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/Title": { "rect": [ 720.0, - 439.0, + 421.0, 62.0, 31.0 ], @@ -12721,6 +12847,15 @@ ], "visible": false }, + "SettingsPopup/CharacterFolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, "SettingsPopup/FolderDialog": { "rect": [ 0.0, @@ -12733,25 +12868,25 @@ "SettingsPopup/SettingsPanel": { "rect": [ 700.0, - 388.0, + 370.0, 520.0, - 304.0 + 339.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer": { "rect": [ 720.0, - 400.0, + 382.0, 480.0, - 280.0 + 315.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer/AddToDatabaseCheckButton": { "rect": [ 720.0, - 400.0, + 382.0, 231.0, 31.0 ], @@ -12760,7 +12895,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow": { "rect": [ 720.0, - 400.0, + 382.0, 331.0, 31.0 ], @@ -12769,7 +12904,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheLabel": { "rect": [ 720.0, - 400.0, + 382.0, 48.0, 23.0 ], @@ -12778,7 +12913,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheTitleLabel": { "rect": [ 720.0, - 400.0, + 382.0, 211.0, 31.0 ], @@ -12787,7 +12922,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/IncreaseCacheButton": { "rect": [ 720.0, - 400.0, + 382.0, 30.0, 31.0 ], @@ -12796,16 +12931,70 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/LowerCacheButton": { "rect": [ 720.0, - 400.0, + 382.0, 30.0, 31.0 ], "visible": false }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersErrorLabel": { + "rect": [ + 720.0, + 382.0, + 1.0, + 26.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow": { + "rect": [ + 720.0, + 382.0, + 183.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/CharactersLabel": { + "rect": [ + 720.0, + 382.0, + 1.0, + 23.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/ChooseCharactersButton": { + "rect": [ + 720.0, + 382.0, + 78.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/Title": { + "rect": [ + 720.0, + 382.0, + 96.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/UseServerCharactersButton": { + "rect": [ + 720.0, + 382.0, + 90.0, + 31.0 + ], + "visible": false + }, "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow": { "rect": [ 720.0, - 400.0, + 382.0, 308.0, 31.0 ], @@ -12814,7 +13003,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/OpenConfigFolderButton": { "rect": [ 720.0, - 400.0, + 382.0, 101.0, 31.0 ], @@ -12823,7 +13012,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 203.0, 31.0 ], @@ -12832,7 +13021,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderErrorLabel": { "rect": [ 720.0, - 400.0, + 382.0, 1.0, 26.0 ], @@ -12841,7 +13030,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow": { "rect": [ 720.0, - 400.0, + 382.0, 195.0, 31.0 ], @@ -12850,7 +13039,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/ChooseFolderButton": { "rect": [ 720.0, - 400.0, + 382.0, 78.0, 31.0 ], @@ -12859,7 +13048,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/FolderLabel": { "rect": [ 720.0, - 400.0, + 382.0, 1.0, 23.0 ], @@ -12868,7 +13057,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 108.0, 31.0 ], @@ -12877,7 +13066,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/UseServerButton": { "rect": [ 720.0, - 400.0, + 382.0, 90.0, 31.0 ], @@ -12886,7 +13075,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FullscreenButton": { "rect": [ 720.0, - 400.0, + 382.0, 127.0, 31.0 ], @@ -12895,7 +13084,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/Label": { "rect": [ 720.0, - 400.0, + 382.0, 97.0, 35.0 ], @@ -12904,7 +13093,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/LowPlayedCheckButton": { "rect": [ 720.0, - 400.0, + 382.0, 285.0, 31.0 ], @@ -12913,7 +13102,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow": { "rect": [ 720.0, - 400.0, + 382.0, 314.0, 31.0 ], @@ -12922,7 +13111,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/PlayOrderButton": { "rect": [ 720.0, - 400.0, + 382.0, 123.0, 31.0 ], @@ -12931,7 +13120,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 187.0, 31.0 ], @@ -12940,7 +13129,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow": { "rect": [ 720.0, - 400.0, + 382.0, 106.0, 31.0 ], @@ -12949,7 +13138,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/SelectServerButton": { "rect": [ 720.0, - 400.0, + 382.0, 40.0, 20.0 ], @@ -12958,7 +13147,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 62.0, 31.0 ], @@ -16728,6 +16917,15 @@ ], "visible": false }, + "SettingsPopup/CharacterFolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, "SettingsPopup/FolderDialog": { "rect": [ 0.0, @@ -16740,25 +16938,25 @@ "SettingsPopup/SettingsPanel": { "rect": [ 700.0, - 388.0, + 370.0, 520.0, - 304.0 + 339.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer": { "rect": [ 720.0, - 400.0, + 382.0, 480.0, - 280.0 + 315.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer/AddToDatabaseCheckButton": { "rect": [ 720.0, - 400.0, + 382.0, 231.0, 31.0 ], @@ -16767,7 +16965,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow": { "rect": [ 720.0, - 400.0, + 382.0, 331.0, 31.0 ], @@ -16776,7 +16974,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheLabel": { "rect": [ 720.0, - 400.0, + 382.0, 48.0, 23.0 ], @@ -16785,7 +16983,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheTitleLabel": { "rect": [ 720.0, - 400.0, + 382.0, 211.0, 31.0 ], @@ -16794,7 +16992,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/IncreaseCacheButton": { "rect": [ 720.0, - 400.0, + 382.0, 30.0, 31.0 ], @@ -16803,16 +17001,70 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/LowerCacheButton": { "rect": [ 720.0, - 400.0, + 382.0, 30.0, 31.0 ], "visible": false }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersErrorLabel": { + "rect": [ + 720.0, + 382.0, + 1.0, + 26.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow": { + "rect": [ + 720.0, + 382.0, + 183.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/CharactersLabel": { + "rect": [ + 720.0, + 382.0, + 1.0, + 23.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/ChooseCharactersButton": { + "rect": [ + 720.0, + 382.0, + 78.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/Title": { + "rect": [ + 720.0, + 382.0, + 96.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/UseServerCharactersButton": { + "rect": [ + 720.0, + 382.0, + 90.0, + 31.0 + ], + "visible": false + }, "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow": { "rect": [ 720.0, - 400.0, + 382.0, 308.0, 31.0 ], @@ -16821,7 +17073,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/OpenConfigFolderButton": { "rect": [ 720.0, - 400.0, + 382.0, 101.0, 31.0 ], @@ -16830,7 +17082,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 203.0, 31.0 ], @@ -16839,7 +17091,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderErrorLabel": { "rect": [ 720.0, - 400.0, + 382.0, 1.0, 26.0 ], @@ -16848,7 +17100,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow": { "rect": [ 720.0, - 400.0, + 382.0, 195.0, 31.0 ], @@ -16857,7 +17109,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/ChooseFolderButton": { "rect": [ 720.0, - 400.0, + 382.0, 78.0, 31.0 ], @@ -16866,7 +17118,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/FolderLabel": { "rect": [ 720.0, - 400.0, + 382.0, 1.0, 23.0 ], @@ -16875,7 +17127,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 108.0, 31.0 ], @@ -16884,7 +17136,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/UseServerButton": { "rect": [ 720.0, - 400.0, + 382.0, 90.0, 31.0 ], @@ -16893,7 +17145,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FullscreenButton": { "rect": [ 720.0, - 400.0, + 382.0, 127.0, 31.0 ], @@ -16902,7 +17154,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/Label": { "rect": [ 720.0, - 400.0, + 382.0, 97.0, 35.0 ], @@ -16911,7 +17163,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/LowPlayedCheckButton": { "rect": [ 720.0, - 400.0, + 382.0, 285.0, 31.0 ], @@ -16920,7 +17172,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow": { "rect": [ 720.0, - 400.0, + 382.0, 314.0, 31.0 ], @@ -16929,7 +17181,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/PlayOrderButton": { "rect": [ 720.0, - 400.0, + 382.0, 123.0, 31.0 ], @@ -16938,7 +17190,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 187.0, 31.0 ], @@ -16947,7 +17199,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow": { "rect": [ 720.0, - 400.0, + 382.0, 106.0, 31.0 ], @@ -16956,7 +17208,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/SelectServerButton": { "rect": [ 720.0, - 400.0, + 382.0, 40.0, 20.0 ], @@ -16965,7 +17217,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 62.0, 31.0 ], @@ -19921,7 +20173,7 @@ ], "visible": true }, - "SettingsPopup/FolderDialog": { + "SettingsPopup/CharacterFolderDialog": { "rect": [ 0.0, 0.0, @@ -19930,251 +20182,6 @@ ], "visible": false }, - "SettingsPopup/SettingsPanel": { - "rect": [ - 700.0, - 388.0, - 520.0, - 304.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer": { - "rect": [ - 720.0, - 400.0, - 480.0, - 280.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/AddToDatabaseCheckButton": { - "rect": [ - 720.0, - 614.0, - 480.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow": { - "rect": [ - 720.0, - 544.0, - 480.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheLabel": { - "rect": [ - 1084.0, - 548.0, - 48.0, - 23.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheTitleLabel": { - "rect": [ - 720.0, - 544.0, - 360.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/IncreaseCacheButton": { - "rect": [ - 1170.0, - 544.0, - 30.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/LowerCacheButton": { - "rect": [ - 1136.0, - 544.0, - 30.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow": { - "rect": [ - 720.0, - 649.0, - 480.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/OpenConfigFolderButton": { - "rect": [ - 1099.0, - 649.0, - 101.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/Title": { - "rect": [ - 720.0, - 649.0, - 375.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/FolderErrorLabel": { - "rect": [ - 720.0, - 400.0, - 1.0, - 26.0 - ], - "visible": false - }, - "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow": { - "rect": [ - 720.0, - 474.0, - 480.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/ChooseFolderButton": { - "rect": [ - 1122.0, - 474.0, - 78.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/FolderLabel": { - "rect": [ - 832.0, - 478.0, - 286.0, - 23.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/Title": { - "rect": [ - 720.0, - 474.0, - 108.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/UseServerButton": { - "rect": [ - 720.0, - 474.0, - 90.0, - 31.0 - ], - "visible": false - }, - "SettingsPopup/SettingsPanel/VBoxContainer/FullscreenButton": { - "rect": [ - 720.0, - 509.0, - 480.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/Label": { - "rect": [ - 720.0, - 400.0, - 480.0, - 35.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/LowPlayedCheckButton": { - "rect": [ - 720.0, - 579.0, - 480.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow": { - "rect": [ - 720.0, - 400.0, - 314.0, - 31.0 - ], - "visible": false - }, - "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/PlayOrderButton": { - "rect": [ - 720.0, - 400.0, - 123.0, - 31.0 - ], - "visible": false - }, - "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/Title": { - "rect": [ - 720.0, - 400.0, - 187.0, - 31.0 - ], - "visible": false - }, - "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow": { - "rect": [ - 720.0, - 439.0, - 480.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/SelectServerButton": { - "rect": [ - 786.0, - 439.0, - 414.0, - 31.0 - ], - "visible": true - }, - "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/Title": { - "rect": [ - 720.0, - 439.0, - 62.0, - 31.0 - ], - "visible": true - } - }, - "popup_SettingsWindow_offline": { - "SettingsPopup": { - "rect": [ - 700.0, - 405.0, - 1920.0, - 1080.0 - ], - "visible": true - }, "SettingsPopup/FolderDialog": { "rect": [ 0.0, @@ -20256,6 +20263,60 @@ ], "visible": true }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersErrorLabel": { + "rect": [ + 720.0, + 382.0, + 1.0, + 26.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow": { + "rect": [ + 720.0, + 491.0, + 480.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/CharactersLabel": { + "rect": [ + 820.0, + 495.0, + 298.0, + 23.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/ChooseCharactersButton": { + "rect": [ + 1122.0, + 491.0, + 78.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/Title": { + "rect": [ + 720.0, + 491.0, + 96.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/UseServerCharactersButton": { + "rect": [ + 720.0, + 491.0, + 90.0, + 31.0 + ], + "visible": false + }, "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow": { "rect": [ 720.0, @@ -20303,7 +20364,7 @@ }, "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/ChooseFolderButton": { "rect": [ - 1028.0, + 1122.0, 456.0, 78.0, 31.0 @@ -20314,7 +20375,7 @@ "rect": [ 832.0, 460.0, - 192.0, + 286.0, 23.0 ], "visible": true @@ -20330,12 +20391,12 @@ }, "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/UseServerButton": { "rect": [ - 1110.0, + 720.0, 456.0, 90.0, 31.0 ], - "visible": true + "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer/FullscreenButton": { "rect": [ @@ -20367,29 +20428,29 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow": { "rect": [ 720.0, - 491.0, - 480.0, + 382.0, + 314.0, 31.0 ], - "visible": true + "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/PlayOrderButton": { "rect": [ - 1077.0, - 491.0, + 720.0, + 382.0, 123.0, 31.0 ], - "visible": true + "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/Title": { "rect": [ 720.0, - 491.0, - 353.0, + 382.0, + 187.0, 31.0 ], - "visible": true + "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow": { "rect": [ @@ -20419,6 +20480,323 @@ "visible": true } }, + "popup_SettingsWindow_offline": { + "SettingsPopup": { + "rect": [ + 700.0, + 405.0, + 1920.0, + 1080.0 + ], + "visible": true + }, + "SettingsPopup/CharacterFolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, + "SettingsPopup/FolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel": { + "rect": [ + 700.0, + 353.0, + 520.0, + 374.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer": { + "rect": [ + 720.0, + 365.0, + 480.0, + 350.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/AddToDatabaseCheckButton": { + "rect": [ + 720.0, + 649.0, + 480.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow": { + "rect": [ + 720.0, + 579.0, + 480.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheLabel": { + "rect": [ + 1084.0, + 583.0, + 48.0, + 23.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheTitleLabel": { + "rect": [ + 720.0, + 579.0, + 360.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/IncreaseCacheButton": { + "rect": [ + 1170.0, + 579.0, + 30.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/LowerCacheButton": { + "rect": [ + 1136.0, + 579.0, + 30.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersErrorLabel": { + "rect": [ + 720.0, + 365.0, + 1.0, + 26.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow": { + "rect": [ + 720.0, + 509.0, + 480.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/CharactersLabel": { + "rect": [ + 820.0, + 513.0, + 298.0, + 23.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/ChooseCharactersButton": { + "rect": [ + 1122.0, + 509.0, + 78.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/Title": { + "rect": [ + 720.0, + 509.0, + 96.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/UseServerCharactersButton": { + "rect": [ + 720.0, + 509.0, + 90.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow": { + "rect": [ + 720.0, + 684.0, + 480.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/OpenConfigFolderButton": { + "rect": [ + 1099.0, + 684.0, + 101.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/Title": { + "rect": [ + 720.0, + 684.0, + 375.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/FolderErrorLabel": { + "rect": [ + 720.0, + 365.0, + 1.0, + 26.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow": { + "rect": [ + 720.0, + 439.0, + 480.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/ChooseFolderButton": { + "rect": [ + 1028.0, + 439.0, + 78.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/FolderLabel": { + "rect": [ + 832.0, + 443.0, + 192.0, + 23.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/Title": { + "rect": [ + 720.0, + 439.0, + 108.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/UseServerButton": { + "rect": [ + 1110.0, + 439.0, + 90.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/FullscreenButton": { + "rect": [ + 720.0, + 544.0, + 480.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/Label": { + "rect": [ + 720.0, + 365.0, + 480.0, + 35.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/LowPlayedCheckButton": { + "rect": [ + 720.0, + 614.0, + 480.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow": { + "rect": [ + 720.0, + 474.0, + 480.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/PlayOrderButton": { + "rect": [ + 1077.0, + 474.0, + 123.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/Title": { + "rect": [ + 720.0, + 474.0, + 353.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow": { + "rect": [ + 720.0, + 404.0, + 480.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/SelectServerButton": { + "rect": [ + 786.0, + 404.0, + 414.0, + 31.0 + ], + "visible": true + }, + "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/Title": { + "rect": [ + 720.0, + 404.0, + 62.0, + 31.0 + ], + "visible": true + } + }, "popup_ShortcutsWindow": { "ShortcutsPopup": { "rect": [ @@ -24581,6 +24959,15 @@ ], "visible": false }, + "SettingsPopup/CharacterFolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, "SettingsPopup/FolderDialog": { "rect": [ 0.0, @@ -24593,25 +24980,25 @@ "SettingsPopup/SettingsPanel": { "rect": [ 700.0, - 388.0, + 370.0, 520.0, - 304.0 + 339.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer": { "rect": [ 720.0, - 400.0, + 382.0, 480.0, - 280.0 + 315.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer/AddToDatabaseCheckButton": { "rect": [ 720.0, - 400.0, + 382.0, 231.0, 31.0 ], @@ -24620,7 +25007,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow": { "rect": [ 720.0, - 400.0, + 382.0, 331.0, 31.0 ], @@ -24629,7 +25016,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheLabel": { "rect": [ 720.0, - 400.0, + 382.0, 48.0, 23.0 ], @@ -24638,7 +25025,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheTitleLabel": { "rect": [ 720.0, - 400.0, + 382.0, 211.0, 31.0 ], @@ -24647,7 +25034,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/IncreaseCacheButton": { "rect": [ 720.0, - 400.0, + 382.0, 30.0, 31.0 ], @@ -24656,16 +25043,70 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/LowerCacheButton": { "rect": [ 720.0, - 400.0, + 382.0, 30.0, 31.0 ], "visible": false }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersErrorLabel": { + "rect": [ + 720.0, + 382.0, + 1.0, + 26.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow": { + "rect": [ + 720.0, + 382.0, + 183.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/CharactersLabel": { + "rect": [ + 720.0, + 382.0, + 1.0, + 23.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/ChooseCharactersButton": { + "rect": [ + 720.0, + 382.0, + 78.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/Title": { + "rect": [ + 720.0, + 382.0, + 96.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/UseServerCharactersButton": { + "rect": [ + 720.0, + 382.0, + 90.0, + 31.0 + ], + "visible": false + }, "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow": { "rect": [ 720.0, - 400.0, + 382.0, 308.0, 31.0 ], @@ -24674,7 +25115,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/OpenConfigFolderButton": { "rect": [ 720.0, - 400.0, + 382.0, 101.0, 31.0 ], @@ -24683,7 +25124,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 203.0, 31.0 ], @@ -24692,7 +25133,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderErrorLabel": { "rect": [ 720.0, - 400.0, + 382.0, 1.0, 26.0 ], @@ -24701,7 +25142,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow": { "rect": [ 720.0, - 400.0, + 382.0, 195.0, 31.0 ], @@ -24710,7 +25151,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/ChooseFolderButton": { "rect": [ 720.0, - 400.0, + 382.0, 78.0, 31.0 ], @@ -24719,7 +25160,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/FolderLabel": { "rect": [ 720.0, - 400.0, + 382.0, 1.0, 23.0 ], @@ -24728,7 +25169,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 108.0, 31.0 ], @@ -24737,7 +25178,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/UseServerButton": { "rect": [ 720.0, - 400.0, + 382.0, 90.0, 31.0 ], @@ -24746,7 +25187,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FullscreenButton": { "rect": [ 720.0, - 400.0, + 382.0, 127.0, 31.0 ], @@ -24755,7 +25196,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/Label": { "rect": [ 720.0, - 400.0, + 382.0, 97.0, 35.0 ], @@ -24764,7 +25205,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/LowPlayedCheckButton": { "rect": [ 720.0, - 400.0, + 382.0, 285.0, 31.0 ], @@ -24773,7 +25214,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow": { "rect": [ 720.0, - 400.0, + 382.0, 314.0, 31.0 ], @@ -24782,7 +25223,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/PlayOrderButton": { "rect": [ 720.0, - 400.0, + 382.0, 123.0, 31.0 ], @@ -24791,7 +25232,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 187.0, 31.0 ], @@ -24800,7 +25241,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow": { "rect": [ 720.0, - 400.0, + 382.0, 106.0, 31.0 ], @@ -24809,7 +25250,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/SelectServerButton": { "rect": [ 720.0, - 400.0, + 382.0, 40.0, 20.0 ], @@ -24818,7 +25259,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 62.0, 31.0 ], @@ -37976,6 +38417,15 @@ ], "visible": false }, + "SettingsPopup/CharacterFolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, "SettingsPopup/FolderDialog": { "rect": [ 0.0, @@ -37988,25 +38438,25 @@ "SettingsPopup/SettingsPanel": { "rect": [ 700.0, - 388.0, + 370.0, 520.0, - 304.0 + 339.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer": { "rect": [ 720.0, - 400.0, + 382.0, 480.0, - 280.0 + 315.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer/AddToDatabaseCheckButton": { "rect": [ 720.0, - 614.0, + 631.0, 480.0, 31.0 ], @@ -38015,7 +38465,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow": { "rect": [ 720.0, - 544.0, + 561.0, 480.0, 31.0 ], @@ -38024,7 +38474,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheLabel": { "rect": [ 1084.0, - 548.0, + 565.0, 48.0, 23.0 ], @@ -38033,7 +38483,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheTitleLabel": { "rect": [ 720.0, - 544.0, + 561.0, 360.0, 31.0 ], @@ -38042,7 +38492,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/IncreaseCacheButton": { "rect": [ 1170.0, - 544.0, + 561.0, 30.0, 31.0 ], @@ -38051,16 +38501,70 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/LowerCacheButton": { "rect": [ 1136.0, - 544.0, + 561.0, 30.0, 31.0 ], "visible": false }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersErrorLabel": { + "rect": [ + 720.0, + 382.0, + 1.0, + 26.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow": { + "rect": [ + 720.0, + 491.0, + 480.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/CharactersLabel": { + "rect": [ + 820.0, + 495.0, + 298.0, + 23.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/ChooseCharactersButton": { + "rect": [ + 1122.0, + 491.0, + 78.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/Title": { + "rect": [ + 720.0, + 491.0, + 96.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/UseServerCharactersButton": { + "rect": [ + 720.0, + 491.0, + 90.0, + 31.0 + ], + "visible": false + }, "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow": { "rect": [ 720.0, - 649.0, + 666.0, 480.0, 31.0 ], @@ -38069,7 +38573,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/OpenConfigFolderButton": { "rect": [ 1099.0, - 649.0, + 666.0, 101.0, 31.0 ], @@ -38078,7 +38582,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/Title": { "rect": [ 720.0, - 649.0, + 666.0, 375.0, 31.0 ], @@ -38087,7 +38591,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderErrorLabel": { "rect": [ 720.0, - 400.0, + 382.0, 1.0, 26.0 ], @@ -38096,7 +38600,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow": { "rect": [ 720.0, - 474.0, + 456.0, 480.0, 31.0 ], @@ -38105,7 +38609,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/ChooseFolderButton": { "rect": [ 1122.0, - 474.0, + 456.0, 78.0, 31.0 ], @@ -38114,7 +38618,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/FolderLabel": { "rect": [ 832.0, - 478.0, + 460.0, 286.0, 23.0 ], @@ -38123,7 +38627,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/Title": { "rect": [ 720.0, - 474.0, + 456.0, 108.0, 31.0 ], @@ -38132,7 +38636,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/UseServerButton": { "rect": [ 720.0, - 474.0, + 456.0, 90.0, 31.0 ], @@ -38141,7 +38645,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FullscreenButton": { "rect": [ 720.0, - 509.0, + 526.0, 480.0, 31.0 ], @@ -38150,7 +38654,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/Label": { "rect": [ 720.0, - 400.0, + 382.0, 480.0, 35.0 ], @@ -38159,7 +38663,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/LowPlayedCheckButton": { "rect": [ 720.0, - 579.0, + 596.0, 480.0, 31.0 ], @@ -38168,7 +38672,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow": { "rect": [ 720.0, - 400.0, + 382.0, 314.0, 31.0 ], @@ -38177,7 +38681,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/PlayOrderButton": { "rect": [ 720.0, - 400.0, + 382.0, 123.0, 31.0 ], @@ -38186,7 +38690,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 187.0, 31.0 ], @@ -38195,7 +38699,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow": { "rect": [ 720.0, - 439.0, + 421.0, 480.0, 31.0 ], @@ -38204,7 +38708,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/SelectServerButton": { "rect": [ 786.0, - 439.0, + 421.0, 414.0, 31.0 ], @@ -38213,7 +38717,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/Title": { "rect": [ 720.0, - 439.0, + 421.0, 62.0, 31.0 ], @@ -42581,6 +43085,15 @@ ], "visible": false }, + "SettingsPopup/CharacterFolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, "SettingsPopup/FolderDialog": { "rect": [ 0.0, @@ -42593,25 +43106,25 @@ "SettingsPopup/SettingsPanel": { "rect": [ 700.0, - 388.0, + 370.0, 520.0, - 304.0 + 339.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer": { "rect": [ 720.0, - 400.0, + 382.0, 480.0, - 280.0 + 315.0 ], "visible": false }, "SettingsPopup/SettingsPanel/VBoxContainer/AddToDatabaseCheckButton": { "rect": [ 720.0, - 614.0, + 631.0, 480.0, 31.0 ], @@ -42620,7 +43133,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow": { "rect": [ 720.0, - 544.0, + 561.0, 480.0, 31.0 ], @@ -42629,7 +43142,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheLabel": { "rect": [ 1084.0, - 548.0, + 565.0, 48.0, 23.0 ], @@ -42638,7 +43151,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/CacheTitleLabel": { "rect": [ 720.0, - 544.0, + 561.0, 360.0, 31.0 ], @@ -42647,7 +43160,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/IncreaseCacheButton": { "rect": [ 1170.0, - 544.0, + 561.0, 30.0, 31.0 ], @@ -42656,16 +43169,70 @@ "SettingsPopup/SettingsPanel/VBoxContainer/CacheRow/LowerCacheButton": { "rect": [ 1136.0, - 544.0, + 561.0, 30.0, 31.0 ], "visible": false }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersErrorLabel": { + "rect": [ + 720.0, + 382.0, + 1.0, + 26.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow": { + "rect": [ + 720.0, + 491.0, + 480.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/CharactersLabel": { + "rect": [ + 820.0, + 495.0, + 298.0, + 23.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/ChooseCharactersButton": { + "rect": [ + 1122.0, + 491.0, + 78.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/Title": { + "rect": [ + 720.0, + 491.0, + 96.0, + 31.0 + ], + "visible": false + }, + "SettingsPopup/SettingsPanel/VBoxContainer/CharactersRow/UseServerCharactersButton": { + "rect": [ + 720.0, + 491.0, + 90.0, + 31.0 + ], + "visible": false + }, "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow": { "rect": [ 720.0, - 649.0, + 666.0, 480.0, 31.0 ], @@ -42674,7 +43241,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/OpenConfigFolderButton": { "rect": [ 1099.0, - 649.0, + 666.0, 101.0, 31.0 ], @@ -42683,7 +43250,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ConfigFolderRow/Title": { "rect": [ 720.0, - 649.0, + 666.0, 375.0, 31.0 ], @@ -42692,7 +43259,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderErrorLabel": { "rect": [ 720.0, - 400.0, + 382.0, 1.0, 26.0 ], @@ -42701,7 +43268,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow": { "rect": [ 720.0, - 474.0, + 456.0, 480.0, 31.0 ], @@ -42710,7 +43277,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/ChooseFolderButton": { "rect": [ 1122.0, - 474.0, + 456.0, 78.0, 31.0 ], @@ -42719,7 +43286,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/FolderLabel": { "rect": [ 832.0, - 478.0, + 460.0, 286.0, 23.0 ], @@ -42728,7 +43295,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/Title": { "rect": [ 720.0, - 474.0, + 456.0, 108.0, 31.0 ], @@ -42737,7 +43304,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FolderRow/UseServerButton": { "rect": [ 720.0, - 474.0, + 456.0, 90.0, 31.0 ], @@ -42746,7 +43313,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/FullscreenButton": { "rect": [ 720.0, - 509.0, + 526.0, 480.0, 31.0 ], @@ -42755,7 +43322,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/Label": { "rect": [ 720.0, - 400.0, + 382.0, 480.0, 35.0 ], @@ -42764,7 +43331,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/LowPlayedCheckButton": { "rect": [ 720.0, - 579.0, + 596.0, 480.0, 31.0 ], @@ -42773,7 +43340,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow": { "rect": [ 720.0, - 400.0, + 382.0, 314.0, 31.0 ], @@ -42782,7 +43349,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/PlayOrderButton": { "rect": [ 720.0, - 400.0, + 382.0, 123.0, 31.0 ], @@ -42791,7 +43358,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/PlayOrderRow/Title": { "rect": [ 720.0, - 400.0, + 382.0, 187.0, 31.0 ], @@ -42800,7 +43367,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow": { "rect": [ 720.0, - 439.0, + 421.0, 480.0, 31.0 ], @@ -42809,7 +43376,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/SelectServerButton": { "rect": [ 786.0, - 439.0, + 421.0, 414.0, 31.0 ], @@ -42818,7 +43385,7 @@ "SettingsPopup/SettingsPanel/VBoxContainer/ServerRow/Title": { "rect": [ 720.0, - 439.0, + 421.0, 62.0, 31.0 ], diff --git a/tests/fakes/main_window_test_suite.gd b/tests/fakes/main_window_test_suite.gd index ef3eb77..f1e7c63 100644 --- a/tests/fakes/main_window_test_suite.gd +++ b/tests/fakes/main_window_test_suite.gd @@ -20,6 +20,10 @@ func before_test() -> void: Playlist.first_song_played = false Playlist.is_song_downloading = false Settings.player_array.clear() + Settings.music_folder = null + Settings.character_folder = "" + # Every test starts a new quiz, unless it saves one in _before_window_starts() + QuizSave.delete() Settings.add_to_stats = true Settings.hide_next_track = true Settings.use_low_played_mode = false @@ -55,6 +59,7 @@ func _before_window_starts() -> void: func after_test() -> void: # Let nodes queued for deletion by the window be freed before orphans are counted await await_idle_frame() + QuizSave.delete() _server.uninstall() _snapshot.restore() diff --git a/tests/fakes/ui_tour.gd b/tests/fakes/ui_tour.gd index 93414b1..44a6a2d 100644 --- a/tests/fakes/ui_tour.gd +++ b/tests/fakes/ui_tour.gd @@ -163,6 +163,10 @@ static func prepare_autoloads() -> void: Playlist.first_song_played = false Playlist.is_song_downloading = false Settings.player_array.clear() + Settings.music_folder = null + Settings.character_folder = "" + # A quiz saved by an earlier run would be continued + QuizSave.delete() Settings.edit_players = false Settings.currently_syncing = false Settings.character_select_open = false @@ -198,7 +202,8 @@ func run(on_state: Callable) -> void: var picture: Image = Image.new() picture.load_png_from_buffer( FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_names()[0]))) - _player_row(0).new_character_selected(ImageTexture.create_from_image(picture)) + _player_row(0).new_character_selected(ImageTexture.create_from_image(picture), + character_names()[0]) _player_row(0).add_point() _player_row(0).add_point() _player_row(1).add_point() diff --git a/tests/integration/test_character_folders.gd b/tests/integration/test_character_folders.gd new file mode 100644 index 0000000..dfff3bf --- /dev/null +++ b/tests/integration/test_character_folders.gd @@ -0,0 +1,192 @@ +extends MainWindowTestSuite +## Characters from a folder on this computer: one picked in the settings, or the +## characters folder of the music folder played. Players pick again when the characters +## change. + +var _settings: SettingsPopup +var _mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3") + + +func _before_window_starts() -> void: + var png: PackedByteArray = Image.create(8, 4, false, Image.FORMAT_RGB8).save_png_to_buffer() + _server.respond_json("/character/list", ["Crono.png", "Frog.png"]) + _server.respond_bytes("/character?name=Crono.png", png) + _server.respond_bytes("/character?name=Frog.png", png) + + +func before_test() -> void: + super() + _settings = _window.settings_popup + await _runner.simulate_frames(2) + + +func _folder(pictures: Array[String], songs: Array[String] = []) -> String: + var root: String = create_temp_dir("character_folders_%d" % randi()) + for file_name: String in pictures: + var path: String = root.path_join(file_name) + DirAccess.make_dir_recursive_absolute(path.get_base_dir()) + FileAccess.open(path, FileAccess.WRITE).store_buffer( + Image.create(16, 8, false, Image.FORMAT_RGB8).save_png_to_buffer()) + for file_name: String in songs: + FileAccess.open(root.path_join(file_name), FileAccess.WRITE).store_buffer(_mp3) + return root + + +func _card_names() -> Array[String]: + var names: Array[String] = [] + for card: CharacterCard in _quiz.character_select_popup.character_grid.get_children(): + if !card.is_queued_for_deletion(): + names.append(card.character_name) + return names + + +func _pick(player: PlayerObject, character_name: String) -> void: + _player_row(player).change_character() + await _runner.simulate_frames(1) + for card: CharacterCard in _quiz.character_select_popup.character_grid.get_children(): + if card.character_name == character_name: + await _click_at(card.get_global_rect().get_center()) + return + fail("No card for " + character_name) + + +func test_server_characters_are_shown_at_first() -> void: + assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"]) + assert_str(_settings.characters_label.text).is_equal("From the server") + assert_bool(_settings.use_server_characters_button.visible).is_false() + + +func test_picking_a_folder_shows_its_characters() -> void: + var folder: String = _folder(["Santa.png", "Elf.jpg"]) + + _settings.use_character_folder(folder) + await _runner.simulate_frames(1) + + assert_array(_card_names()).contains_exactly(["Elf.jpg", "Santa.png"]) + assert_str(_settings.characters_label.text).is_equal(folder.get_file()) + assert_bool(_settings.use_server_characters_button.visible).is_true() + + +func test_character_from_a_folder_is_picked_with_its_picture_and_name() -> void: + _settings.use_character_folder(_folder(["Santa.png"])) + var crono: PlayerObject = _add_player("Crono") + await _runner.simulate_frames(1) + + await _pick(crono, "Santa.png") + + assert_str(crono.character_name).is_equal("Santa.png") + assert_vector(crono.character.get_size()).is_equal(Vector2(16, 8)) + + +func test_picking_a_folder_resets_the_players_characters() -> void: + var crono: PlayerObject = _add_player("Crono") + await _pick(crono, "Frog.png") + assert_str(crono.character_name).is_equal("Frog.png") + + _settings.use_character_folder(_folder(["Santa.png"])) + await _runner.simulate_frames(1) + + assert_str(crono.character_name).is_empty() + assert_str(crono.character.resource_path).is_equal(PlayerObject.NO_CHARACTER_PATH) + assert_object(_player_row(crono).character.texture_normal).is_same(crono.character) + + +func test_folder_without_pictures_is_not_used() -> void: + var crono: PlayerObject = _add_player("Crono") + await _pick(crono, "Frog.png") + + _settings.use_character_folder(_folder([], ["01. Song.mp3"])) + + assert_bool(_settings.characters_error_label.visible).is_true() + assert_str(_settings.characters_error_label.text).starts_with("No pictures found in") + assert_str(Settings.character_folder).is_empty() + assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"]) + assert_str(crono.character_name).is_equal("Frog.png") + + +func test_use_server_goes_back_to_the_server_characters() -> void: + _settings.use_character_folder(_folder(["Santa.png"])) + var crono: PlayerObject = _add_player("Crono") + await _pick(crono, "Santa.png") + + _settings.use_server_characters() + await _runner.simulate_frames(1) + + assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"]) + assert_str(_settings.characters_label.text).is_equal("From the server") + assert_bool(_settings.use_server_characters_button.visible).is_false() + assert_str(crono.character_name).is_empty() + + +func test_music_folder_with_characters_brings_its_own() -> void: + var folder: String = _folder(["characters/Santa.png", "characters/Elf.png"], + ["01. Jingle Bells.mp3"]) + var crono: PlayerObject = _add_player("Crono") + await _pick(crono, "Frog.png") + + _settings.use_music_folder(folder) + await _runner.simulate_frames(2) + + assert_array(_card_names()).contains_exactly(["Elf.png", "Santa.png"]) + assert_str(_settings.characters_label.text).is_equal("From " + folder.get_file()) + assert_str(crono.character_name).is_empty() + # A prepared quiz with characters is still a prepared quiz + assert_bool(Settings.is_prepared_quiz()).is_true() + + +func test_music_folder_without_characters_keeps_the_server_characters() -> void: + var crono: PlayerObject = _add_player("Crono") + await _pick(crono, "Frog.png") + + _settings.use_music_folder(_folder([], ["01. Song.mp3"])) + await _runner.simulate_frames(2) + + assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"]) + assert_str(crono.character_name).is_equal("Frog.png") + + +func test_leaving_a_music_folder_with_characters_goes_back_to_the_server() -> void: + _settings.use_music_folder(_folder(["characters/Santa.png"], ["01. Song.mp3"])) + await _runner.simulate_frames(2) + var crono: PlayerObject = _add_player("Crono") + await _pick(crono, "Santa.png") + + _settings.use_server() + await _runner.simulate_frames(2) + + assert_array(_card_names()).contains_exactly(["Crono.png", "Frog.png"]) + assert_str(crono.character_name).is_empty() + + +func test_picked_folder_is_used_before_the_music_folders_characters() -> void: + var picked: String = _folder(["Snowman.png"]) + _settings.use_character_folder(picked) + await _runner.simulate_frames(1) + + _settings.use_music_folder(_folder(["characters/Santa.png"], ["01. Song.mp3"])) + await _runner.simulate_frames(2) + + assert_array(_card_names()).contains_exactly(["Snowman.png"]) + assert_str(_settings.characters_label.text).is_equal(picked.get_file()) + + +func test_loading_the_same_characters_again_keeps_the_players_characters() -> void: + var crono: PlayerObject = _add_player("Crono") + await _pick(crono, "Frog.png") + + # A sync reloads the characters from the same server + _quiz.on_sync_finished() + await _runner.simulate_frames(2) + + assert_str(crono.character_name).is_equal("Frog.png") + + +func test_the_characters_folder_is_kept_for_this_session_only() -> void: + _settings.use_character_folder(_folder(["Santa.png"])) + Settings.save_settings() + + var saved: ConfigFile = ConfigFile.new() + saved.load(Settings.saved_settings_path()) + + for section: String in saved.get_sections(): + assert_bool(saved.has_section_key(section, "character_folder")).is_false() diff --git a/tests/integration/test_character_folders.gd.uid b/tests/integration/test_character_folders.gd.uid new file mode 100644 index 0000000..8ce0583 --- /dev/null +++ b/tests/integration/test_character_folders.gd.uid @@ -0,0 +1 @@ +uid://dcjuwndgpwled diff --git a/tests/integration/test_character_select_popup.gd b/tests/integration/test_character_select_popup.gd index 1238c18..da4989c 100644 --- a/tests/integration/test_character_select_popup.gd +++ b/tests/integration/test_character_select_popup.gd @@ -24,7 +24,8 @@ func before_test() -> void: super() _picked.clear() _popup = _quiz.get_node("CharacterSelectPopup") - _popup.character_selected.connect(func(texture: Texture2D) -> void: _picked.append(texture)) + _popup.character_selected.connect( + func(texture: Texture2D, _name: String) -> void: _picked.append(texture)) # Opened from the player's row, like clicking the player's picture _crono = _add_player("Crono") _player_row(_crono).change_character() diff --git a/tests/integration/test_continue_quiz.gd b/tests/integration/test_continue_quiz.gd new file mode 100644 index 0000000..3d4de85 --- /dev/null +++ b/tests/integration/test_continue_quiz.gd @@ -0,0 +1,413 @@ +extends MainWindowTestSuite +## The quiz is saved after every change and goes on where it was when the game starts +## again: players, points, characters and songs, from the server or a music folder. A new +## game, a changed server playlist or a file that can't be read start over. + +const SECOND_SONG: Dictionary = {"SongNo": 1, "Soundtrack": "Final Fantasy VI", + "Song": "Dancing Mad"} + +var _mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3") + + +func _saved() -> QuizSave: + # Saving waits until the end of the frame + await _runner.simulate_frames(1) + return QuizSave.read() + + +## Closes the game and starts it again, like the user does: everything in memory is gone +## and only the files are left. The server answers with [param server_songs] as its +## playlist, the songs it handed out before. +func _restart_game(server_songs: Array = SONG_INFO) -> void: + await _runner.simulate_frames(1) + _window.free() + Settings.player_array.clear() + Settings.music_folder = null + Settings.character_folder = "" + Settings.add_to_stats = true + Playlist.song_object_array.clear() + Playlist.currently_playing_song = -1 + Playlist.latest_played_song = 0 + Playlist.game_start_song = 0 + Playlist.first_song_played = false + Playlist.is_song_downloading = false + Playlist._song_files_written = 0 + _server.respond_json("/music/list", server_songs) + _server.requested_paths.clear() + _runner = scene_runner("res://main/MainWindow.tscn") + _window = _runner.scene() + _quiz = _window.get_node("Quiz") + await _runner.simulate_frames(3) + + +# Crono and Marle play the first song; Crono gets the point and the answer is shown. The +# second song is downloaded ahead. +func _play_a_little() -> Array[PlayerObject]: + var crono: PlayerObject = _add_player("Crono") + var marle: PlayerObject = _add_player("Marle") + _next_song_is(SECOND_SONG) + await _play_first_song() + await _give_point_with_keybind(1) + _press("SongControls/ShowAnswerButton") + await _runner.simulate_frames(2) + return [crono, marle] + + +func _now_playing() -> Array[String]: + var now_playing: Node = _quiz.get_node("NowPlaying") + return [(now_playing.get_node("SoundtrackLabel") as Label).text, + (now_playing.get_node("SongLabel") as Label).text] + + +# Saving + +func test_players_points_and_songs_are_saved_as_the_quiz_goes_on() -> void: + var players: Array[PlayerObject] = await _play_a_little() + + var saved: QuizSave = await _saved() + + assert_array(saved.players.map(func(p: PlayerObject) -> String: return p.player_name)) \ + .contains_exactly(["Crono", "Marle"]) + assert_int(saved.players[0].player_score).is_equal(1) + assert_int(saved.songs.size()).is_equal(2) + assert_array(saved.songs[0].players_given_point).contains_exactly([players[0].id]) + assert_bool(saved.songs[0].is_answered).is_true() + assert_bool(saved.songs[0].is_playing).is_true() + assert_bool(saved.songs[1].has_played).is_false() + assert_int(saved.currently_playing_song).is_equal(0) + + +func test_taking_a_point_is_saved() -> void: + await _play_a_little() + + await _take_point_with_keybind(1) + + var saved: QuizSave = await _saved() + assert_int(saved.players[0].player_score).is_equal(0) + assert_array(saved.songs[0].players_given_point).is_empty() + + +func test_picking_a_character_is_saved() -> void: + var crono: PlayerObject = _add_player("Crono") + _player_row(crono).change_character() + var texture: ImageTexture = ImageTexture.create_from_image( + Image.create(4, 4, false, Image.FORMAT_RGB8)) + + _quiz.character_select_popup.character_selected.emit(texture, "Frog.png") + + assert_str((await _saved()).players[0].character_name).is_equal("Frog.png") + + +func test_removing_a_player_is_saved() -> void: + var players: Array[PlayerObject] = await _play_a_little() + + await _remove_player(players[0]) + + var saved: QuizSave = await _saved() + assert_int(saved.players.size()).is_equal(1) + assert_array(saved.songs[0].players_given_point).is_empty() + + +func test_reset_points_saves_a_new_game() -> void: + await _play_a_little() + + _press("QuizToolbar/ResetPointsButton") + + var saved: QuizSave = await _saved() + assert_int(saved.players.size()).is_equal(2) + assert_int(saved.players[0].player_score).is_equal(0) + assert_array(saved.songs[0].players_given_point).is_empty() + assert_int(saved.game_start_song).is_equal(1) + + +func test_a_sync_saves_a_new_game() -> void: + await _play_a_little() + + _quiz.on_sync_finished() + await _runner.simulate_frames(3) + + var saved: QuizSave = await _saved() + assert_int(saved.players[0].player_score).is_equal(0) + assert_str(saved.players[0].player_name).is_equal("Crono") + + +func test_winning_is_saved_with_statistics_off() -> void: + Settings.winning_score = 1 + await _play_a_little() + + assert_bool((await _saved()).stats_paused_by_win).is_true() + + +# Continuing after a restart + +func test_quiz_goes_on_after_a_restart() -> void: + var before: Array[PlayerObject] = await _play_a_little() + + await _restart_game([SONG_INFO[0], SECOND_SONG]) + + assert_int(Settings.player_array.size()).is_equal(2) + var crono: PlayerObject = Settings.player_array[0] + assert_str(crono.player_name).is_equal("Crono") + assert_str(crono.id).is_equal(before[0].id) + assert_int(crono.player_score).is_equal(1) + assert_bool(crono.is_first_point).is_false() + assert_int(Playlist.song_object_array.size()).is_equal(2) + assert_array(Playlist.song_object_array[0].players_given_point).contains_exactly([crono.id]) + assert_bool(Playlist.song_object_array[0].is_answered).is_true() + assert_bool(Playlist.song_object_array[1].has_played).is_false() + assert_int(Playlist.currently_playing_song).is_equal(0) + assert_array(_now_playing()).contains_exactly(["Chrono Trigger", "Corridors of Time"]) + # The server's playlist was kept, not reset + assert_int(_server.count("/music/reset")).is_equal(0) + + +func test_players_rows_show_the_saved_scores() -> void: + await _play_a_little() + + await _restart_game([SONG_INFO[0], SECOND_SONG]) + + var row: PlayerRow = _player_row(Settings.player_array[0]) + assert_str(row.player_name_field.text).ends_with("Crono: 1") + + +func test_song_downloaded_ahead_is_kept_and_not_downloaded_again() -> void: + await _play_a_little() + var file: String = (await _saved()).songs[1].song_path + + await _restart_game([SONG_INFO[0], SECOND_SONG]) + + assert_bool(FileAccess.file_exists(file)).is_true() + assert_int(_server.count("/music/rand")).is_equal(0) + assert_bool((_quiz.get_node("SongControls/NextButton") as Button).disabled).is_false() + + +func test_next_plays_the_song_downloaded_before_the_restart() -> void: + await _play_a_little() + await _restart_game([SONG_INFO[0], SECOND_SONG]) + + _press("SongControls/NextButton") + await await_millis(700) + + assert_int(Playlist.currently_playing_song).is_equal(1) + assert_bool(_quiz.music_player.audio_player.playing).is_true() + + +func test_song_that_was_playing_is_ready_to_play_again() -> void: + await _play_a_little() + await _restart_game([SONG_INFO[0], SECOND_SONG]) + var player: MusicPlayer = _quiz.music_player + + assert_object(player.audio_player.stream).is_not_null() + assert_bool(player.audio_player.playing).is_false() + assert_bool(player.shows_playing()).is_false() + + player.play_or_pause() + + assert_bool(player.audio_player.playing).is_true() + + +func test_keybinds_give_points_after_a_restart() -> void: + await _play_a_little() + await _restart_game([SONG_INFO[0], SECOND_SONG]) + + await _give_point_with_keybind(2) + + assert_array(_scores()).contains_exactly([1, 1]) + assert_int((await _saved()).players[1].player_score).is_equal(1) + + +func test_song_files_that_are_gone_are_downloaded_again() -> void: + await _play_a_little() + var saved: QuizSave = await _saved() + for song_object: SongObject in saved.songs: + DirAccess.remove_absolute(song_object.song_path) + _server.respond_bytes("/music?song=0", _mp3) + _server.respond_bytes("/music?song=1", _mp3) + + await _restart_game([SONG_INFO[0], SECOND_SONG]) + + # The song that was playing and the one downloaded ahead + assert_int(_server.count("/music?song=0")).is_equal(1) + assert_int(_server.count("/music?song=1")).is_equal(1) + assert_object(Playlist.get_song(1)).is_not_null() + + +func test_unused_song_files_are_deleted_at_start() -> void: + await _play_a_little() + var stray: String = Playlist.songs_folder().path_join("left_over.mp3") + FileAccess.open(stray, FileAccess.WRITE).store_buffer(_mp3) + + await _restart_game([SONG_INFO[0], SECOND_SONG]) + + assert_bool(FileAccess.file_exists(stray)).is_false() + assert_int(DirAccess.get_files_at(Playlist.songs_folder()).size()).is_equal(2) + + +func test_changed_server_playlist_starts_over_with_the_same_players() -> void: + await _play_a_little() + + # The server's playlist was reset, e.g. by a sync from another computer + await _restart_game([{"SongNo": 0, "Soundtrack": "Terranigma", "Song": "Underworld"}]) + + assert_array(Settings.player_array.map(func(p: PlayerObject) -> String: + return p.player_name)).contains_exactly(["Crono", "Marle"]) + assert_array(_scores()).contains_exactly([0, 0]) + assert_str(Playlist.song_object_array[0].soundtrack_title).is_equal("Terranigma") + assert_bool(Playlist.song_object_array[0].has_played).is_true() + + +func test_songs_the_server_added_after_the_save_are_kept_as_played() -> void: + await _play_a_little() + var third: Dictionary = {"SongNo": 2, "Soundtrack": "Terranigma", "Song": "Underworld"} + + await _restart_game([SONG_INFO[0], SECOND_SONG, third]) + + assert_int(Playlist.song_object_array.size()).is_equal(3) + assert_int(Settings.player_array[0].player_score).is_equal(1) + assert_bool(Playlist.song_object_array[2].has_played).is_true() + + +func test_another_server_starts_over_with_the_same_players() -> void: + await _play_a_little() + var server_before: String = Settings.default_path + Settings.default_path = "https://other.example.com" + + await _restart_game([SONG_INFO[0], SECOND_SONG]) + + Settings.default_path = server_before + assert_int(Settings.player_array.size()).is_equal(2) + assert_array(_scores()).contains_exactly([0, 0]) + + +func test_file_that_cannot_be_read_starts_a_new_quiz() -> void: + await _play_a_little() + await _runner.simulate_frames(1) + FileAccess.open(QuizSave.path(), FileAccess.WRITE).store_string("{ half a fi") + + await _restart_game([SONG_INFO[0], SECOND_SONG]) + + assert_array(Settings.player_array).is_empty() + # Like any start without a saved quiz: the server's songs count as played, and a new + # one is downloaded ahead + assert_bool(Playlist.song_object_array[0].has_played).is_true() + assert_bool(Playlist.song_object_array[1].has_played).is_true() + assert_int(Playlist.song_object_array.size()).is_equal(3) + # The broken file is replaced by the new quiz + assert_object(await _saved()).is_not_null() + + +func test_statistics_stay_off_after_a_won_game() -> void: + Settings.winning_score = 1 + await _play_a_little() + + await _restart_game([SONG_INFO[0], SECOND_SONG]) + + assert_bool(Settings.add_to_stats).is_false() + _press("QuizToolbar/ResetPointsButton") + assert_bool(Settings.add_to_stats).is_true() + + +func test_nothing_is_saved_before_the_saved_songs_are_back() -> void: + await _play_a_little() + _server.hold("/music/list") + await _restart_game([SONG_INFO[0], SECOND_SONG]) + var file_before: String = FileAccess.get_file_as_string(QuizSave.path()) + + # A player added while the server has not answered yet + _add_player("Lucca") + await _runner.simulate_frames(2) + + assert_str(FileAccess.get_file_as_string(QuizSave.path())).is_equal(file_before) + _server.release("/music/list") + await _runner.simulate_frames(2) + assert_int((await _saved()).players.size()).is_equal(3) + assert_int((await _saved()).songs.size()).is_equal(2) + + +# Characters + +func test_character_picture_comes_back_after_a_restart() -> void: + var picture: Image = Image.create(20, 10, false, Image.FORMAT_RGB8) + _server.respond_bytes("/character?name=Frog.png", picture.save_png_to_buffer()) + var crono: PlayerObject = _add_player("Crono") + _player_row(crono).new_character_selected(ImageTexture.create_from_image(picture), + "Frog.png") + _quiz.save_soon() + + await _restart_game() + + crono = Settings.player_array[0] + assert_str(crono.character_name).is_equal("Frog.png") + assert_vector(crono.character.get_size()).is_equal(Vector2(20, 10)) + assert_object(_player_row(crono).character.texture_normal).is_same(crono.character) + + +func test_characters_from_a_folder_picked_for_the_session_are_reset_after_a_restart() -> void: + var folder: String = create_temp_dir("session_characters_%d" % randi()) + FileAccess.open(folder.path_join("Santa.png"), FileAccess.WRITE).store_buffer( + Image.create(8, 4, false, Image.FORMAT_RGB8).save_png_to_buffer()) + _window.settings_popup.use_character_folder(folder) + var crono: PlayerObject = _add_player("Crono") + _player_row(crono).new_character_selected(ImageTexture.create_from_image( + Image.create(8, 4, false, Image.FORMAT_RGB8)), "Santa.png") + _quiz.save_soon() + + # The folder is only used until the game is closed + await _restart_game() + + assert_str(Settings.player_array[0].character_name).is_empty() + assert_str(Settings.player_array[0].character.resource_path) \ + .is_equal(PlayerObject.NO_CHARACTER_PATH) + + +# A music folder + +func _prepared_quiz_folder() -> String: + var root: String = create_temp_dir("continue_offline_%d" % randi()) + for file_name: String in ["01. Opening.mp3", "02. Jingle Bells.mp3", "03. Finale.mp3"]: + FileAccess.open(root.path_join(file_name), FileAccess.WRITE).store_buffer(_mp3) + return root + + +func test_quiz_from_a_music_folder_goes_on_offline() -> void: + var folder: String = _prepared_quiz_folder() + _window.settings_popup.use_music_folder(folder) + await _runner.simulate_frames(2) + _add_player("Crono") + _press("SongControls/NextButton") + await await_millis(700) + await _give_point_with_keybind(1) + + await _restart_game() + + assert_bool(Settings.is_offline()).is_true() + assert_str(Settings.music_folder.path).is_equal(folder) + assert_bool(_window.top_bar.offline_label.visible).is_true() + assert_int(Settings.player_array[0].player_score).is_equal(1) + assert_str(Playlist.song_object_array[0].song_title).is_equal("01. Opening") + assert_array(_server.requested_paths.filter(func(path: String) -> bool: + return path.begins_with("/music"))).is_empty() + + _press("SongControls/NextButton") + await await_millis(700) + assert_str(Playlist.song_object_array[Playlist.currently_playing_song].song_title) \ + .is_equal("02. Jingle Bells") + + +func test_music_folder_that_is_gone_starts_over_with_the_server() -> void: + var folder: String = _prepared_quiz_folder() + _window.settings_popup.use_music_folder(folder) + await _runner.simulate_frames(2) + _add_player("Crono") + _press("SongControls/NextButton") + await await_millis(700) + await _give_point_with_keybind(1) + for file_name: String in DirAccess.get_files_at(folder): + DirAccess.remove_absolute(folder.path_join(file_name)) + + await _restart_game() + + assert_bool(Settings.is_offline()).is_false() + assert_int(Settings.player_array.size()).is_equal(1) + assert_array(_scores()).contains_exactly([0]) + assert_str(Playlist.song_object_array[0].soundtrack_title).is_equal("Chrono Trigger") diff --git a/tests/integration/test_continue_quiz.gd.uid b/tests/integration/test_continue_quiz.gd.uid new file mode 100644 index 0000000..5e553b8 --- /dev/null +++ b/tests/integration/test_continue_quiz.gd.uid @@ -0,0 +1 @@ +uid://dgxvnsquk18ud diff --git a/tests/integration/test_scene_split.gd b/tests/integration/test_scene_split.gd index 7da3745..550094c 100644 --- a/tests/integration/test_scene_split.gd +++ b/tests/integration/test_scene_split.gd @@ -165,8 +165,9 @@ func test_character_button_in_the_player_list_picks_a_new_character() -> void: assert_bool(character_select.visible).is_true() var texture: ImageTexture = ImageTexture.create_from_image( Image.create(4, 4, false, Image.FORMAT_RGBA8)) - character_select.character_selected.emit(texture) + character_select.character_selected.emit(texture, "Frog.png") assert_object(crono.character).is_same(texture) + assert_str(crono.character_name).is_equal("Frog.png") func test_sound_test_button_fetches_the_sound_test_song() -> void: diff --git a/tests/unit/test_folder_characters.gd b/tests/unit/test_folder_characters.gd new file mode 100644 index 0000000..626098b --- /dev/null +++ b/tests/unit/test_folder_characters.gd @@ -0,0 +1,94 @@ +extends GdUnitTestSuite +## Characters read from a folder on this computer instead of the server. + +var _root: String + + +func before_test() -> void: + _root = create_temp_dir("folder_characters_%d" % randi()) + + +func _picture(file_name: String, size: Vector2i = Vector2i(8, 4)) -> Image: + var image: Image = Image.create(size.x, size.y, false, Image.FORMAT_RGB8) + image.fill(Color.FIREBRICK) + var bytes: PackedByteArray + match file_name.get_extension().to_lower(): + "png": + bytes = image.save_png_to_buffer() + "jpg", "jpeg": + bytes = image.save_jpg_to_buffer() + "webp": + bytes = image.save_webp_to_buffer() + _file(file_name, bytes) + return image + + +func _file(file_name: String, bytes: PackedByteArray) -> void: + FileAccess.open(_root.path_join(file_name), FileAccess.WRITE).store_buffer(bytes) + + +func test_every_picture_is_a_character_in_natural_order() -> void: + for file_name: String in ["Santa10.png", "Santa2.jpg", "elf.webp", "Rudolph.JPEG"]: + _picture(file_name) + + assert_array(FolderCharacters.picture_names(_root)).contains_exactly( + ["elf.webp", "Rudolph.JPEG", "Santa2.jpg", "Santa10.png"]) + + +func test_other_and_hidden_files_are_left_out() -> void: + _picture("Santa.png") + _picture(".hidden.png") + _file("notes.txt", "hello".to_utf8_buffer()) + _file("song.mp3", PackedByteArray([1, 2, 3])) + DirAccess.make_dir_absolute(_root.path_join("more.png")) + + assert_array(FolderCharacters.picture_names(_root)).contains_exactly(["Santa.png"]) + + +func test_missing_folder_has_no_characters() -> void: + assert_array(FolderCharacters.picture_names(_root.path_join("gone"))).is_empty() + + +func test_load_list_gives_the_names() -> void: + _picture("Santa.png") + _picture("Elf.png") + var names: Array = [] + + FolderCharacters.new(_root).load_list(func(list: Array[String]) -> void: names.assign(list)) + + assert_array(names).contains_exactly(["Elf.png", "Santa.png"]) + + +func test_fetch_image_reads_the_picture() -> void: + _picture("Santa.webp", Vector2i(20, 10)) + var pictures: Array[Image] = [] + + FolderCharacters.new(_root).fetch_image("Santa.webp", + func(picture: Image) -> void: pictures.append(picture)) + + assert_int(pictures.size()).is_equal(1) + assert_vector(Vector2(pictures[0].get_size())).is_equal(Vector2(20, 10)) + + +func test_fetch_image_of_a_missing_or_broken_picture_gives_nothing() -> void: + _file("Broken.png", "not a picture".to_utf8_buffer()) + var pictures: Array[Image] = [] + var characters: FolderCharacters = FolderCharacters.new(_root) + + characters.fetch_image("Gone.png", func(picture: Image) -> void: pictures.append(picture)) + characters.fetch_image("Broken.png", func(picture: Image) -> void: pictures.append(picture)) + + assert_array(pictures).is_empty() + + +func test_names_cannot_reach_outside_the_folder() -> void: + var outside: String = _root.get_base_dir().path_join("outside_%d.png" % randi()) + FileAccess.open(outside, FileAccess.WRITE).store_buffer( + Image.create(4, 4, false, Image.FORMAT_RGB8).save_png_to_buffer()) + var pictures: Array[Image] = [] + + FolderCharacters.new(_root).fetch_image("../" + outside.get_file(), + func(picture: Image) -> void: pictures.append(picture)) + + DirAccess.remove_absolute(outside) + assert_array(pictures).is_empty() diff --git a/tests/unit/test_folder_characters.gd.uid b/tests/unit/test_folder_characters.gd.uid new file mode 100644 index 0000000..aadab3f --- /dev/null +++ b/tests/unit/test_folder_characters.gd.uid @@ -0,0 +1 @@ +uid://du26d6jxqs6gf diff --git a/tests/unit/test_music_folder.gd b/tests/unit/test_music_folder.gd index 059e839..84a1876 100644 --- a/tests/unit/test_music_folder.gd +++ b/tests/unit/test_music_folder.gd @@ -200,3 +200,124 @@ func test_display_name_is_the_folder_name() -> void: var folder: MusicFolder = _folder("Musiktävling 2025/") assert_str(folder.display_name()).is_equal("Musiktävling 2025") + + +# A quiz's own characters + +func _picture(relative_path: String) -> void: + _file(relative_path, Image.create(8, 4, false, Image.FORMAT_RGB8).save_png_to_buffer()) + + +func test_prepared_quiz_with_a_characters_folder_is_still_a_prepared_quiz() -> void: + _song("01. Jingle Bells.mp3") + _song("02. Last Christmas.mp3") + _picture("characters/Santa.png") + + var folder: MusicFolder = _folder() + + assert_int(folder.kind).is_equal(MusicFolder.Kind.PREPARED_QUIZ) + assert_int(folder.quiz_size()).is_equal(2) + assert_str(folder.characters_path).is_equal(_root.path_join("characters")) + + +func test_characters_folder_is_not_a_soundtrack() -> void: + _song("Chrono Trigger/Corridors of Time.mp3") + _picture("Characters/Frog.png") + + var folder: MusicFolder = _folder() + + assert_int(folder.kind).is_equal(MusicFolder.Kind.SOUNDTRACKS) + assert_array(folder.soundtrack_names()).contains_exactly(["Chrono Trigger"]) + assert_str(folder.characters_path).is_equal(_root.path_join("Characters")) + + +func test_characters_folder_without_pictures_is_not_used() -> void: + _song("01. Opening.mp3") + _file("characters/notes.txt", "not a picture".to_utf8_buffer()) + + var folder: MusicFolder = _folder() + + assert_int(folder.kind).is_equal(MusicFolder.Kind.PREPARED_QUIZ) + assert_str(folder.characters_path).is_empty() + + +func test_only_a_characters_folder_is_nothing_to_play() -> void: + _picture("characters/Santa.png") + + assert_int(_folder().kind).is_equal(MusicFolder.Kind.EMPTY) + + +func test_folder_without_characters_has_no_characters_path() -> void: + _song("Chrono Trigger/Corridors of Time.mp3") + + assert_str(_folder().characters_path).is_empty() + + +# Continuing a prepared quiz + +func _prepared_quiz(songs: int) -> MusicFolder: + for i: int in songs: + _song("%02d. Song.mp3" % (i + 1)) + return _folder() + + +func test_prepared_quiz_continues_where_it_was() -> void: + var folder: MusicFolder = _prepared_quiz(4) + folder.next_song() + folder.next_song() + + var again: MusicFolder = _folder() + assert_bool(again.restore_progress(folder.progress())).is_true() + + assert_str(again.next_song().song_title).is_equal("03. Song") + assert_str(again.next_song().song_title).is_equal("04. Song") + assert_object(again.next_song()).is_null() + + +func test_random_prepared_quiz_continues_in_the_same_order() -> void: + var folder: MusicFolder = _prepared_quiz(8) + folder.set_play_order(MusicFolder.PlayOrder.RANDOM) + folder.next_song() + var saved: Dictionary = JSON.parse_string(JSON.stringify(folder.progress())) + var expected: Array[String] = [] + for i: int in 7: + expected.append(folder.next_song().song_title) + + var again: MusicFolder = _folder() + again.restore_progress(saved) + + assert_int(again.play_order).is_equal(MusicFolder.PlayOrder.RANDOM) + var titles: Array[String] = [] + for i: int in 7: + titles.append(again.next_song().song_title) + assert_array(titles).contains_exactly(expected) + + +func test_songs_removed_from_the_folder_are_left_out_when_continuing() -> void: + var folder: MusicFolder = _prepared_quiz(4) + folder.next_song() + folder.next_song() + var saved: Dictionary = folder.progress() + # One played song and one still to play are gone + DirAccess.remove_absolute(_root.path_join("01. Song.mp3")) + DirAccess.remove_absolute(_root.path_join("04. Song.mp3")) + + var again: MusicFolder = _folder() + again.restore_progress(saved) + + assert_str(again.next_song().song_title).is_equal("03. Song") + assert_object(again.next_song()).is_null() + + +func test_progress_that_does_not_fit_changes_nothing() -> void: + var folder: MusicFolder = _prepared_quiz(2) + + assert_bool(folder.restore_progress({})).is_false() + assert_bool(folder.restore_progress({"queue": "nope", "next": 1})).is_false() + assert_str(folder.next_song().song_title).is_equal("01. Song") + + +func test_soundtrack_folder_has_no_progress_to_continue() -> void: + _song("Chrono Trigger/Corridors of Time.mp3") + + assert_bool(_folder().restore_progress({"queue": [], "next": 0})).is_false() diff --git a/tests/unit/test_quiz_save.gd b/tests/unit/test_quiz_save.gd new file mode 100644 index 0000000..a8afd06 --- /dev/null +++ b/tests/unit/test_quiz_save.gd @@ -0,0 +1,302 @@ +extends GdUnitTestSuite +## The saved quiz: what is saved from the game, writing and reading the file, and files +## that can't be used. + +var _snapshot: AutoloadSnapshot +var _config_dir_before: String + + +func before_test() -> void: + _snapshot = AutoloadSnapshot.take([Settings, Playlist]) + _config_dir_before = OS.get_environment(Settings.CONFIG_DIR_ENV) + OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("quiz_save_%d" % randi())) + Settings.is_debug = false + Settings.music_folder = null + Settings.character_folder = "" + Settings.default_path = "https://music.example.com" + Settings.player_array.clear() + Playlist.song_object_array.clear() + + +func after_test() -> void: + OS.set_environment(Settings.CONFIG_DIR_ENV, _config_dir_before) + _snapshot.restore() + + +func _player(player_name: String, score: int, character: String = "") -> PlayerObject: + var player: PlayerObject = PlayerObject.new(player_name) + player.player_score = score + player.is_first_point = score == 0 + player.character_name = character + Settings.player_array.append(player) + return player + + +func _song(number: int, played: bool, points: Array[String] = []) -> SongObject: + var song_object: SongObject = SongObject.new() + song_object.song_number = number + song_object.soundtrack_title = "Soundtrack %d" % number + song_object.song_title = "Song %d" % number + song_object.has_played = played + song_object.is_answered = played + song_object.song_path = "/songs/song_%d.mp3" % number + for player_id: String in points: + song_object.add_point(player_id) + Playlist.song_object_array.append(song_object) + return song_object + + +# A quiz with two players, two played songs and one downloaded ahead +func _quiz() -> void: + var crono: PlayerObject = _player("Crono", 2, "Crono.png") + var marle: PlayerObject = _player("Marle", 1) + _song(0, true, [crono.id]) + _song(1, true, [crono.id, marle.id]) + _song(2, false) + Playlist.song_object_array[1].is_playing = true + Playlist.currently_playing_song = 1 + Playlist.latest_played_song = 1 + Playlist.game_start_song = 0 + Playlist.first_song_played = true + + +func _read_back() -> QuizSave: + assert_bool(QuizSave.capture(false).write()).is_true() + return QuizSave.read() + + +# What is saved + +func test_players_are_saved_with_points_and_characters() -> void: + _quiz() + + var players: Array[PlayerObject] = _read_back().players + + assert_int(players.size()).is_equal(2) + assert_str(players[0].player_name).is_equal("Crono") + assert_str(players[0].id).is_equal(Settings.player_array[0].id) + assert_int(players[0].player_score).is_equal(2) + assert_bool(players[0].is_first_point).is_false() + assert_str(players[0].character_name).is_equal("Crono.png") + assert_str(players[1].character_name).is_empty() + + +func test_songs_are_saved_with_who_got_points_and_how_far_they_came() -> void: + _quiz() + + var songs: Array[SongObject] = _read_back().songs + + assert_int(songs.size()).is_equal(3) + var crono: String = Settings.player_array[0].id + var marle: String = Settings.player_array[1].id + assert_array(songs[0].players_given_point).contains_exactly([crono]) + assert_array(songs[1].players_given_point).contains_exactly([crono, marle]) + assert_bool(songs[1].is_playing).is_true() + assert_bool(songs[1].has_played).is_true() + assert_bool(songs[2].has_played).is_false() + assert_bool(songs[2].is_answered).is_false() + assert_str(songs[2].song_title).is_equal("Song 2") + assert_str(songs[2].soundtrack_title).is_equal("Soundtrack 2") + assert_str(songs[2].song_path).is_equal("/songs/song_2.mp3") + + +func test_playlist_positions_are_saved() -> void: + _quiz() + + var save: QuizSave = _read_back() + + assert_int(save.currently_playing_song).is_equal(1) + assert_int(save.latest_played_song).is_equal(1) + assert_int(save.game_start_song).is_equal(0) + assert_bool(save.first_song_played).is_true() + + +func test_where_the_songs_and_characters_came_from_is_saved() -> void: + _quiz() + Settings.character_folder = "/home/me/Christmas characters" + assert_bool(QuizSave.capture(true).write()).is_true() + + var save: QuizSave = QuizSave.read() + + assert_str(save.server).is_equal("https://music.example.com") + assert_str(save.music_folder_path).is_empty() + assert_str(save.characters_dir).is_equal("/home/me/Christmas characters") + assert_bool(save.stats_paused_by_win).is_true() + + +func test_a_music_folder_is_saved_with_how_far_its_quiz_has_come() -> void: + var root: String = create_temp_dir("quiz_save_folder_%d" % randi()) + for file_name: String in ["01. One.mp3", "02. Two.mp3"]: + FileAccess.open(root.path_join(file_name), FileAccess.WRITE).store_buffer( + FileAccess.get_file_as_bytes("res://tests/data/short.mp3")) + Settings.music_folder = MusicFolder.scan(root) + Settings.music_folder.next_song() + + var save: QuizSave = _read_back() + + assert_str(save.server).is_empty() + assert_str(save.music_folder_path).is_equal(root) + assert_int(int(save.music_folder_progress.next)).is_equal(1) + + +func test_song_files_are_the_saved_songs_files() -> void: + _quiz() + Playlist.song_object_array[0].song_path = "" + + assert_array(_read_back().song_files()).contains_exactly( + ["/songs/song_1.mp3", "/songs/song_2.mp3"]) + + +# The file + +func test_quiz_is_saved_next_to_the_settings_and_apart_in_debug_mode() -> void: + assert_str(QuizSave.path()).is_equal(Settings.config_dir().path_join("current_quiz.json")) + + Settings.is_debug = true + + assert_str(QuizSave.path()).is_equal( + Settings.config_dir().path_join("current_quiz_debug.json")) + + +func test_no_file_means_no_saved_quiz() -> void: + assert_object(QuizSave.read()).is_null() + + +func test_saving_leaves_no_temporary_file() -> void: + _quiz() + + QuizSave.capture(false).write() + + assert_array(DirAccess.get_files_at(Settings.config_dir())).contains_exactly( + ["current_quiz.json"]) + + +func test_saving_again_replaces_the_quiz() -> void: + _quiz() + QuizSave.capture(false).write() + Settings.player_array[0].player_score = 5 + + assert_int(_read_back().players[0].player_score).is_equal(5) + + +func test_delete_removes_the_saved_quiz() -> void: + _quiz() + QuizSave.capture(false).write() + + QuizSave.delete() + + assert_object(QuizSave.read()).is_null() + + +func test_file_that_is_not_a_quiz_is_ignored() -> void: + for text: String in ["", "not json {", "[1, 2]", '{"version": 1}', + '{"players": [], "songs": []}', '{"version": 99, "players": [], "songs": []}']: + FileAccess.open(QuizSave.path(), FileAccess.WRITE).store_string(text) + + assert_object(QuizSave.read()).override_failure_message(text).is_null() + + +func test_players_that_cannot_be_read_are_left_out() -> void: + _quiz() + var data: Dictionary = QuizSave.capture(false).to_dict() + data.players.append({"name": 3}) + data.players.append("Lucca") + + var save: QuizSave = QuizSave.from_dict(data) + + assert_int(save.players.size()).is_equal(2) + + +func test_songs_are_dropped_together_if_one_cannot_be_read() -> void: + _quiz() + var data: Dictionary = QuizSave.capture(false).to_dict() + data.songs[1] = {"song": "no number"} + + var save: QuizSave = QuizSave.from_dict(data) + + # A song's place in the list is its number, so the others can't be kept without it + assert_array(save.songs).is_empty() + assert_int(save.currently_playing_song).is_equal(-1) + assert_int(save.players.size()).is_equal(2) + + +func test_positions_outside_the_songs_are_kept_inside() -> void: + _quiz() + var data: Dictionary = QuizSave.capture(false).to_dict() + data.currently_playing_song = 40 + data.latest_played_song = -3 + data.game_start_song = 99 + + var save: QuizSave = QuizSave.from_dict(data) + + assert_int(save.currently_playing_song).is_equal(2) + assert_int(save.latest_played_song).is_equal(0) + assert_int(save.game_start_song).is_equal(3) + + +func test_no_more_players_than_the_game_allows() -> void: + var data: Dictionary = QuizSave.new().to_dict() + for i: int in Settings.MAX_NUMBER_OF_PLAYERS + 3: + data.players.append(PlayerObject.new("Player %d" % i).to_dict()) + + assert_int(QuizSave.from_dict(data).players.size()).is_equal(Settings.MAX_NUMBER_OF_PLAYERS) + + +func test_drop_songs_keeps_the_players_without_points() -> void: + _quiz() + var save: QuizSave = _read_back() + + save.drop_songs() + + assert_array(save.songs).is_empty() + assert_int(save.currently_playing_song).is_equal(-1) + assert_int(save.players.size()).is_equal(2) + assert_int(save.players[0].player_score).is_equal(0) + assert_bool(save.players[0].is_first_point).is_true() + assert_str(save.players[0].character_name).is_equal("Crono.png") + + +# Players and songs on their own + +func test_player_round_trip() -> void: + var player: PlayerObject = _player("Lucca", 3, "Lucca.png") + + var again: PlayerObject = PlayerObject.from_dict( + JSON.parse_string(JSON.stringify(player.to_dict()))) + + assert_str(again.id).is_equal(player.id) + assert_str(again.player_name).is_equal("Lucca") + assert_int(again.player_score).is_equal(3) + assert_str(again.character_name).is_equal("Lucca.png") + # The picture is found again by name later + assert_str(again.character.resource_path).is_equal(PlayerObject.NO_CHARACTER_PATH) + + +func test_song_round_trip() -> void: + var song_object: SongObject = _song(7, true, ["a", "b"]) + song_object.is_playing = true + + var again: SongObject = SongObject.from_dict( + JSON.parse_string(JSON.stringify(song_object.to_dict()))) + + assert_int(again.song_number).is_equal(7) + assert_str(again.soundtrack_title).is_equal("Soundtrack 7") + assert_str(again.song_title).is_equal("Song 7") + assert_array(again.players_given_point).contains_exactly(["a", "b"]) + assert_bool(again.has_played).is_true() + assert_bool(again.is_answered).is_true() + assert_bool(again.is_playing).is_true() + assert_str(again.song_path).is_equal("/songs/song_7.mp3") + + +func test_clear_character_goes_back_to_no_character() -> void: + var player: PlayerObject = _player("Crono", 0) + var texture: ImageTexture = ImageTexture.create_from_image( + Image.create(4, 4, false, Image.FORMAT_RGB8)) + player.set_character(texture, "Crono.png") + assert_object(player.character).is_same(texture) + + player.clear_character() + + assert_str(player.character_name).is_empty() + assert_str(player.character.resource_path).is_equal(PlayerObject.NO_CHARACTER_PATH) diff --git a/tests/unit/test_quiz_save.gd.uid b/tests/unit/test_quiz_save.gd.uid new file mode 100644 index 0000000..e3cb930 --- /dev/null +++ b/tests/unit/test_quiz_save.gd.uid @@ -0,0 +1 @@ +uid://5mphqqq032dx diff --git a/tests/unit/test_song_files.gd b/tests/unit/test_song_files.gd index 30d2c99..609cf4b 100644 --- a/tests/unit/test_song_files.gd +++ b/tests/unit/test_song_files.gd @@ -83,3 +83,27 @@ func test_song_stays_in_memory_when_it_cannot_be_saved() -> void: assert_str(song_object.song_path).is_empty() assert_object(Playlist.get_song(0)).is_instanceof(AudioStreamMP3) + + +func test_new_songs_never_write_over_files_kept_from_a_saved_quiz() -> void: + var kept: SongObject = _add_song() + var kept_bytes: PackedByteArray = FileAccess.get_file_as_bytes(kept.song_path) + # As after a restart: the count of files written starts over + Playlist._song_files_written = 0 + + var new_song: SongObject = _add_song() + + assert_str(new_song.song_path).is_not_equal(kept.song_path) + assert_array(FileAccess.get_file_as_bytes(kept.song_path)).is_equal(kept_bytes) + assert_array(DirAccess.get_files_at(Playlist.songs_folder())).has_size(2) + + +func test_clearing_can_keep_the_files_a_saved_quiz_uses() -> void: + var kept: SongObject = _add_song() + var dropped: SongObject = _add_song() + + Playlist.clear_song_files(PackedStringArray([kept.song_path])) + + assert_bool(FileAccess.file_exists(kept.song_path)).is_true() + assert_bool(FileAccess.file_exists(dropped.song_path)).is_false() +