Save the quiz and continue it after a restart; load characters from a folder
The quiz is saved to current_quiz.json in the config folder after every change, and the game goes on where it was when it starts again: the players with their points and characters, every song with who got a point for it and whether it was played, answered or is playing, and the songs downloaded ahead with their files. The song that was playing is ready to play again. Songs whose files are gone are downloaded again. On a server, the saved songs are checked against the server's playlist. If it changed, e.g. after a sync, or another server is used, the quiz starts over with the same players. A quiz from a music folder continues offline, including how far a prepared quiz has come. A new game is saved like any other change, and a file that can't be read starts a new quiz. Characters can be read from a folder picked in Settings instead of the server, for this session. A music folder with a characters folder brings its own characters, e.g. for a themed quiz; that folder is not counted as a soundtrack. When the characters come from somewhere else, players pick their characters again. Closes #11, closes #44
This commit is contained in:
1 parent
4a934840f6
commit
e4352a9f34
33 files changed
+2886
-499
No files matched your search
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bc2nkmd162ibl
|
||||
+44
-2
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+12
-9
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfabuhf854ukp
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+21
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
+2
-2
@@ -57,9 +57,9 @@ func change_character() -> void:
|
||||
change_character_clicked.emit()
|
||||
|
||||
|
||||
func new_character_selected(character_texture: ImageTexture) -> void:
|
||||
func new_character_selected(character_texture: Texture, character_name: String) -> void:
|
||||
GameLog.debug("Back in player list with new")
|
||||
player_object.character = character_texture
|
||||
player_object.set_character(character_texture, character_name)
|
||||
set_player_character()
|
||||
|
||||
|
||||
|
||||
+130
-4
@@ -6,6 +6,9 @@ extends Control
|
||||
## The main window shows the quiz below its top bar. Pictures and buttons keep the
|
||||
## positions they had in the main window, so the quiz fills the whole window and lets
|
||||
## clicks through where it has nothing.
|
||||
##
|
||||
## The quiz is saved after every change and continued when the game starts again, see
|
||||
## [QuizSave].
|
||||
|
||||
## Emitted when another MusicServer is picked in the settings, after the quiz has
|
||||
## reloaded from it.
|
||||
@@ -26,6 +29,11 @@ var stats_paused_by_win: bool = false
|
||||
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
|
||||
var popup_backdrop: PopupBackdrop
|
||||
|
||||
# True from start until the saved quiz's songs are back, so a half restored quiz is
|
||||
# never saved over the whole one
|
||||
var _restoring: bool = false
|
||||
var _save_queued: bool = false
|
||||
|
||||
@onready var inspiration_list: InspirationList = $InspirationList
|
||||
@onready var now_playing: NowPlaying = $NowPlaying
|
||||
@onready var players_panel: PlayersPanel = $PlayersPanel
|
||||
@@ -50,7 +58,7 @@ func _ready() -> void:
|
||||
quiz_settings_button.pressed.connect(quiz_settings_card.toggle)
|
||||
toolbar.reset_playlist_pressed.connect(reset_playlist)
|
||||
toolbar.reset_points_pressed.connect(reset_points)
|
||||
song_loader.song_list_loaded.connect(update_song_list)
|
||||
song_loader.song_list_loaded.connect(_on_song_list_loaded)
|
||||
song_loader.song_added.connect(_on_song_added)
|
||||
|
||||
character_select_popup.character_selected.connect(_on_new_character_selected)
|
||||
@@ -60,10 +68,15 @@ func _ready() -> void:
|
||||
players_panel.change_character_requested.connect(_on_change_character_requested)
|
||||
music_player.play_next_song.connect(play_next_song)
|
||||
music_player.update_song_list.connect(update_song_list)
|
||||
character_select_popup.source_changed.connect(_on_character_source_changed)
|
||||
song_loader.saved_songs_dropped.connect(_on_saved_songs_dropped)
|
||||
|
||||
var saved: QuizSave = QuizSave.read()
|
||||
_continue_saved_quiz(saved)
|
||||
inspiration_list.load_soundtracks()
|
||||
song_loader.load_song_list()
|
||||
song_loader.load_song_list(saved)
|
||||
character_select_popup.load_characters()
|
||||
_load_saved_characters(saved)
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
@@ -151,6 +164,7 @@ func _on_song_added() -> void:
|
||||
song_controls.set_next_enabled(true)
|
||||
if Settings.is_debug:
|
||||
update_song_list()
|
||||
save_soon()
|
||||
|
||||
|
||||
func update_song_list() -> void:
|
||||
@@ -159,6 +173,114 @@ func update_song_list() -> void:
|
||||
now_playing.show_song(Playlist.get_current_soundtrack_title(),
|
||||
Playlist.get_current_song_title())
|
||||
music_list_panel.refresh()
|
||||
save_soon()
|
||||
|
||||
|
||||
## Saves the quiz at the end of this frame, once however many changes it had.
|
||||
func save_soon() -> void:
|
||||
if _restoring || _save_queued:
|
||||
return
|
||||
_save_queued = true
|
||||
_save_now.call_deferred()
|
||||
|
||||
|
||||
func _save_now() -> void:
|
||||
_save_queued = false
|
||||
if is_inside_tree():
|
||||
QuizSave.capture(stats_paused_by_win).write()
|
||||
|
||||
|
||||
# Puts back the saved quiz's music folder, players and statistics. Its songs come back
|
||||
# with the song list, see SongLoader.load_song_list(), and its characters' pictures once
|
||||
# the characters are loaded.
|
||||
func _continue_saved_quiz(saved: QuizSave) -> void:
|
||||
if saved == null:
|
||||
Playlist.clear_song_files()
|
||||
return
|
||||
_restoring = true
|
||||
if !saved.music_folder_path.is_empty():
|
||||
var folder: MusicFolder = MusicFolder.scan(saved.music_folder_path)
|
||||
if folder.kind == MusicFolder.Kind.EMPTY:
|
||||
GameLog.warning("The saved quiz's music folder is gone: ", saved.music_folder_path)
|
||||
saved.drop_songs()
|
||||
else:
|
||||
folder.restore_progress(saved.music_folder_progress)
|
||||
Settings.music_folder = folder
|
||||
elif saved.server != Settings.default_path:
|
||||
# Another server has another playlist
|
||||
saved.drop_songs()
|
||||
Playlist.clear_song_files(saved.song_files())
|
||||
for player: PlayerObject in saved.players:
|
||||
Settings.player_array.append(player)
|
||||
_on_player_added(player)
|
||||
if saved.characters_dir != Settings.characters_dir():
|
||||
for player: PlayerObject in saved.players:
|
||||
player.clear_character()
|
||||
players_panel.reload()
|
||||
if saved.stats_paused_by_win:
|
||||
stats_paused_by_win = true
|
||||
Settings.add_to_stats = false
|
||||
|
||||
|
||||
# The players' characters are saved by name; their pictures come from the characters
|
||||
func _load_saved_characters(saved: QuizSave) -> void:
|
||||
if saved == null:
|
||||
return
|
||||
for player: PlayerObject in saved.players:
|
||||
var character_name: String = player.character_name
|
||||
if character_name.is_empty():
|
||||
continue
|
||||
character_select_popup.source.fetch_image(character_name, func(picture: Image) -> void:
|
||||
# Another character may have been picked meanwhile
|
||||
if player.character_name == character_name:
|
||||
player.character = ImageTexture.create_from_image(picture)
|
||||
players_panel.reload())
|
||||
|
||||
|
||||
func _on_song_list_loaded() -> void:
|
||||
if _restoring:
|
||||
_finish_restoring()
|
||||
update_song_list()
|
||||
|
||||
|
||||
# The saved songs are back: the song that was playing is ready to play again, and songs
|
||||
# whose files are gone are downloaded again
|
||||
func _finish_restoring() -> void:
|
||||
_restoring = false
|
||||
if !Settings.is_offline():
|
||||
for i: int in Playlist.song_object_array.size():
|
||||
var song_object: SongObject = Playlist.song_object_array[i]
|
||||
var needed: bool = !song_object.has_played || i == Playlist.currently_playing_song
|
||||
if needed && Playlist.get_song(i) == null:
|
||||
MusicServerClient.fetch_song(song_object.song_number,
|
||||
_on_saved_song_downloaded.bind(i))
|
||||
if Playlist.currently_playing_song >= 0:
|
||||
music_player.load_song_object(Playlist.currently_playing_song)
|
||||
if Playlist.get_preloaded_songs() > 0:
|
||||
song_controls.set_next_enabled(true)
|
||||
|
||||
|
||||
func _on_saved_song_downloaded(body: PackedByteArray, song_no: int) -> void:
|
||||
if song_no >= Playlist.song_object_array.size():
|
||||
return
|
||||
Playlist.add_song_to_song_object(song_no, body)
|
||||
if song_no == Playlist.currently_playing_song && !music_player.audio_player.playing:
|
||||
music_player.load_song_object(song_no)
|
||||
save_soon()
|
||||
|
||||
|
||||
func _on_saved_songs_dropped() -> void:
|
||||
for player: PlayerObject in Settings.player_array:
|
||||
player.reset_points()
|
||||
players_panel.reload()
|
||||
|
||||
|
||||
# Characters picked before are not among the new ones, so everyone picks again
|
||||
func _on_character_source_changed() -> void:
|
||||
for player: PlayerObject in Settings.player_array:
|
||||
player.clear_character()
|
||||
players_panel.reload()
|
||||
save_soon()
|
||||
|
||||
|
||||
func song_object_clicked(song_no: int) -> void:
|
||||
@@ -204,6 +326,7 @@ func reset_points() -> void:
|
||||
if stats_paused_by_win:
|
||||
Settings.add_to_stats = true
|
||||
stats_paused_by_win = false
|
||||
save_soon()
|
||||
|
||||
|
||||
# Each new player's points reach the music player, the log and the winner popup.
|
||||
@@ -214,6 +337,7 @@ func _on_player_added(new_player_object: PlayerObject) -> void:
|
||||
new_player_object.point_given_sound.connect(_on_make_point_given_sound)
|
||||
new_player_object.player_point_given.connect(_on_point_given.bind(new_player_object))
|
||||
new_player_object.player_point_taken.connect(_on_point_taken.bind(new_player_object))
|
||||
save_soon()
|
||||
|
||||
|
||||
func _on_point_given(player_object: PlayerObject) -> void:
|
||||
@@ -259,6 +383,7 @@ func _on_player_won(player_object: PlayerObject) -> void:
|
||||
if Settings.add_to_stats:
|
||||
Settings.add_to_stats = false
|
||||
stats_paused_by_win = true
|
||||
save_soon()
|
||||
|
||||
|
||||
func _on_player_removed(player_object: PlayerObject) -> void:
|
||||
@@ -273,6 +398,7 @@ func _on_change_character_requested(new_player: PlayerRow, player_name: String)
|
||||
character_select_popup.show_grid(player_name)
|
||||
|
||||
|
||||
func _on_new_character_selected(character_texture: ImageTexture) -> void:
|
||||
func _on_new_character_selected(character_texture: ImageTexture, character_name: String) -> void:
|
||||
GameLog.debug("_on_new_character_selected")
|
||||
current_player_row.new_character_selected(character_texture)
|
||||
current_player_row.new_character_selected(character_texture, character_name)
|
||||
save_soon()
|
||||
+57
-11
@@ -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
|
||||
|
||||
+1022
-455
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dcjuwndgpwled
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dgxvnsquk18ud
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://du26d6jxqs6gf
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://5mphqqq032dx
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user