class_name UiTour extends RefCounted ## Walks the main window through a fixed set of states against a fake MusicServer: ## startup, players added, a song playing, every popup open, a sync and the statistics ## tab. ## ## Used for screenshots ([code]tools/take_screenshots.gd[/code]) and for the layout ## test, so both look at exactly the same states. Nodes are looked up by name in the ## main window and in its Quiz and TopBar scenes, so the tour works no matter which of ## them a node lives in. const SOUNDTRACKS: Array[String] = [ "Chrono Trigger", "Final Fantasy VI", "Secret of Mana", "Terranigma", "Earthbound", "Super Metroid", "The Legend of Zelda: A Link to the Past", "Donkey Kong Country 2", "Lufia II", "Illusion of Gaia", ] const SONG_INFO: Dictionary = { "SongNo": 0, "Soundtrack": "Chrono Trigger", "SoundtrackUuid": "soundtrack-0", "Song": "Corridors of Time", } const SYNC_PROGRESS: Dictionary = {"progress": 40, "time_spent": "00:02"} const SYNC_RESULT: Dictionary = { "soundtracks_added": ["Terranigma", "Lufia II"], "soundtracks_re_added": [], "soundtracks_changed_title": {"FF6": "Final Fantasy VI"}, "soundtracks_changed_content": ["Chrono Trigger"], "soundtracks_removed": ["Old Soundtrack"], "catched_errors": [], "total_time": "00:03", } ## The server's version history, newest first. const SERVER_HISTORY: Array[Dictionary] = [ {"version": "5.0.0", "changelog": [ "#9 - Major routes refactor and expansion", "#15 - Added API for statistics", "Token authentication for /api/v1", ]}, {"version": "4.5.0", "changelog": [ "#1 - Created request to check newest version of the app", "#2 - Added request to download the newest version of the app", ]}, {"version": "4.0.0", "changelog": ["Changed framework from gin to Echo", "Reorganized the code"]}, ] ## Song names of the first soundtrack in the statistics, to show Japanese text. const JAPANESE_SONGS: Array[String] = [ "時の回廊", "カエルのテーマ", "風の憧憬", "魔王決戦", "遥かなる時の彼方へ", ] ## The character pictures the fake server hands out. const CHARACTERS_DIR: String = "res://characters" ## Small copies of covers from the MusicServer, 2:3 like all its covers. const COVERS_DIR: String = "res://tests/data/covers" ## Popups in the order the tour opens them: the state name, then the popup's node. The ## state names are what the popups were first called, so screenshots taken before they ## were renamed can still be compared with new ones. const POPUPS: Array[Array] = [ ["Search", "SearchPopup"], ["SyncWindow", "SyncPopup"], ["SettingsWindow", "SettingsPopup"], ["QRWindow", "QRPopup"], ["NewCharacterSelect", "CharacterSelectPopup"], ["ShortcutsWindow", "ShortcutsPopup"], ["AboutWindow", "AboutPopup"], ["WinnerWindow", "WinnerPopup"], ["Log", "LogPopup"], ["QuizSettings", "QuizSettingsCard"], ] var _main: Control var _scores_before_win: Array[int] = [] var _winning_score_before_win: int = 0 func _init(main: Control) -> void: _main = main ## Returns a fake server that answers every request the main window makes on the tour. ## Call [method FakeHttpTransport.install] before the main window is created. static func make_server() -> FakeHttpTransport: var server: FakeHttpTransport = FakeHttpTransport.new() var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3") server.respond_json("/music/all", SOUNDTRACKS) server.respond_json("/music/all/order", SOUNDTRACKS) server.respond_json("/music/list", []) server.respond_json("/download", "1.0.0") var characters: Array[String] = character_names() server.respond_json("/character/list", characters) for character_name: String in characters: server.respond_bytes("/character?name=" + character_name.uri_encode(), FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_name))) server.respond_bytes("/music/rand", mp3) server.respond_bytes("/music/addQue", PackedByteArray()) server.respond_json("/music/info", SONG_INFO) server.respond_bytes("/music/reset", PackedByteArray()) server.respond_bytes("/music/played?song=0", PackedByteArray()) server.respond_bytes("/sync", PackedByteArray()) server.respond_json("/sync/progress", SYNC_PROGRESS) server.respond_json("/statistics/soundtracks", soundtrack_statistics()) # Real covers from the server, made smaller, for every other soundtrack for i: int in range(0, SOUNDTRACKS.size(), 2): server.respond_bytes("/soundtracks/soundtrack-%d/cover" % i, FileAccess.get_file_as_bytes(COVERS_DIR.path_join("cover_%d.jpg" % (i >> 1)))) # The soundtrack list tells the quiz which soundtracks have covers var soundtracks: Array[Dictionary] = [] for soundtrack: Dictionary in soundtrack_statistics(): soundtracks.append({"uuid": soundtrack.soundtrack_uuid, "name": soundtrack.soundtrack_name, "cover_modified": soundtrack.get("cover_modified", 0), "alternative_names": soundtrack.get("alternative_names", [])}) server.respond_json("/soundtracks", soundtracks) server.respond_json("/version/history", SERVER_HISTORY) return server ## Play statistics for every soundtrack in [constant SOUNDTRACKS], the same on every ## run. Some have never been played. static func soundtrack_statistics() -> Array[Dictionary]: var statistics: Array[Dictionary] = [] for i: int in SOUNDTRACKS.size(): var played: int = (i * 7) % 11 var songs: Array[Dictionary] = [] for song: int in 5: songs.append({ "song_uuid": "song-%d-%d" % [i, song], "soundtrack_uuid": "soundtrack-%d" % i, "soundtrack_name": SOUNDTRACKS[i], "song_name": JAPANESE_SONGS[song] if i == 0 else "Track %d" % (song + 1), "times_played": maxi(0, played - song * 2), }) var soundtrack: Dictionary = { "soundtrack_uuid": "soundtrack-%d" % i, "soundtrack_name": SOUNDTRACKS[i], "soundtrack_played": played, "songs": songs, } if played > 0: soundtrack.soundtrack_last_played = "2026-09-%02dT20:00:00Z" % (i + 1) # Every other soundtrack has a cover, the rest show their initials if i % 2 == 0: soundtrack.cover_modified = 1700000000 if i == 0: soundtrack.alternative_names = ["CT", "クロノ・トリガー"] statistics.append(soundtrack) return statistics ## Who scored on each song of a 60-song game to 20 points, the same on every run: the ## third of [param players] wins on the last song, nobody scores on songs 21–27 and the ## others stay below 20. Needs at least three players. static func long_game(players: Array[PlayerObject]) -> Array: var random: RandomNumberGenerator = RandomNumberGenerator.new() random.seed = 7 var winner: int = 2 var scores: Array[int] = [] scores.resize(players.size()) var song_scorers: Array = [] for song: int in 60: var scorers: Array[String] = [] for index: int in players.size(): var limit: int = 19 if index == winner else 18 var chance: float = 0.36 if index == winner else 0.2 + 0.02 * index if song < 59 && (song < 20 || song >= 27) && scores[index] < limit \ && random.randf() < chance: scorers.append(players[index].id) scores[index] += 1 song_scorers.append(scorers) song_scorers[59].append(players[winner].id) return song_scorers ## The pictures in [constant CHARACTERS_DIR], sorted by name. static func character_names() -> Array[String]: var names: Array[String] = [] for file_name: String in DirAccess.get_files_at(CHARACTERS_DIR): if file_name.ends_with(".png"): names.append(file_name) names.sort() return names ## Puts Settings and Playlist in the state the tour expects. Call it before the main ## window is created. static func prepare_autoloads() -> void: Playlist.reset_playlist() 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 Settings.add_to_stats = true Settings.hide_next_track = true Settings.random_mode = Settings.RandomMode.NORMAL Settings.winning_score = 20 Settings.last_seen_version = Settings.version # The same picture every time, without half-finished animations Settings.reduce_animations = true # The inspiration list scrolls by itself, which would make every picture different Settings.inspiration_list_speed = 0 ## Runs the tour. [param on_state] is called (and awaited) with the name of each state ## and the popup that is open in it, or [code]null[/code] when no popup is open. func run(on_state: Callable) -> void: _stop_caret_blink(_main) await _settle() await on_state.call("startup", null) _call("add_players") await _settle() await on_state.call("add_player_row", null) _add_player("Crono") _add_player("Marle") _call("add_players") await _settle() await on_state.call("players_added", null) _call("play_next_song") await _settle() _freeze_music_player() 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), character_names()[0]) _player_row(0).add_point() _player_row(0).add_point() _player_row(1).add_point() _call("show_answer_pressed") await _settle() _freeze_music_player() _fix_log_times() await _settle() await on_state.call("playing", null) for state_and_node: Array in POPUPS: var popup: Node = await _open_popup(state_and_node[1]) await _settle() await on_state.call("popup_" + state_and_node[0], popup) _close_popup(popup) await _settle() var search_popup: SearchPopup = _node("SearchPopup") search_popup.open() search_popup.search_field.text = "lttp" search_popup.search() await _settle() await on_state.call("popup_Search_typed", search_popup) search_popup.close() await _settle() var sync_popup: BasePopup = _node("SyncPopup") Settings.currently_syncing = false sync_popup.has_synced = false sync_popup.start_sync() sync_popup.sync_timer.stop() sync_popup._on_sync_timeout() await _settle() await on_state.call("sync_in_progress", sync_popup) sync_popup.close() await _settle() await on_state.call("sync_buttons_locked", null) _server().respond_json("/sync/progress", SYNC_RESULT) sync_popup._on_sync_timeout() sync_popup.open() await _settle() await on_state.call("sync_result", sync_popup) sync_popup.close() await _settle() var top_bar: TopBar = _node("TopBar") top_bar.show_latest_version("99.10.10") await _settle() await on_state.call("update_available", null) var update_popup: UpdatePopup = _node("UpdatePopup") var updater: Updater = update_popup.updater updater.in_editor = false updater.os_name = "Windows" DirAccess.make_dir_recursive_absolute(Settings.config_dir()) updater.executable_path = Settings.config_dir().path_join("MusicPlayer.exe") update_popup.show_update("99.10.10") await _settle() await on_state.call("popup_UpdateWindow", update_popup) update_popup.download_progress.visible = true update_popup._set_busy(true) update_popup._show_status("") update_popup._on_progress(31 << 20, 80 << 20) await _settle() await on_state.call("popup_UpdateWindow_downloading", update_popup) updater.in_editor = true update_popup.show_update("99.10.10") await _settle() await on_state.call("popup_UpdateWindow_browser_only", update_popup) update_popup.close() await _settle() top_bar.show_latest_version("1.0.0") await _settle() var statistics_view: StatisticsView = _node("StatisticsView") _main.show_tab(&"statistics") await _settle() await on_state.call("statistics", statistics_view) statistics_view.show_option.select(StatisticsView.ShowFilter.NEVER_PLAYED) statistics_view.apply_filter() await _settle() await on_state.call("statistics_never_played", statistics_view) statistics_view.show_option.select(StatisticsView.ShowFilter.ALL) statistics_view.sort_option.select(StatisticsView.SortOrder.MOST_PLAYED) statistics_view.apply_sort() statistics_view.search_field.text = "chrono" statistics_view.apply_filter() await _settle() await on_state.call("statistics_search", statistics_view) # Chrono Trigger has never been played, so the song list searches for another statistics_view.search_field.text = "zelda" statistics_view.set_mode(StatisticsView.Mode.SONGS) await _settle() await on_state.call("statistics_songs_search", statistics_view) statistics_view.clear_search() await _settle() await on_state.call("statistics_songs", statistics_view) statistics_view.search_field.text = "chrono" statistics_view.set_mode(StatisticsView.Mode.SOUNDTRACKS) await _settle() var stats_popup: SoundtrackStatsPopup = _node("SoundtrackStatsPopup") for card: SoundtrackCard in statistics_view.cards: if card.visible: stats_popup.show_soundtrack(card.soundtrack) break await _settle() await on_state.call("popup_SoundtrackStats", stats_popup) stats_popup.edit_names() stats_popup.new_name_field.text = "Chrono" await _settle() await on_state.call("popup_SoundtrackStats_names", stats_popup) stats_popup.change_cover() stats_popup.cover_id_field.text = "5248061" await _settle() await on_state.call("popup_SoundtrackStats_cover", stats_popup) stats_popup.stop_editing() stats_popup.close() _main.show_tab(&"quiz") await _settle() # Last, as it adds players: a long game with five players on the Winner popup for player_name: String in ["Lucca", "Frog", "Robo"]: _add_player(player_name) await _settle() var players: Array[PlayerObject] = Settings.player_array var song_scorers: Array = long_game(players) var history: ScoreHistory = ScoreHistory.from_songs(players, song_scorers) for player: PlayerObject in players: player.player_score = history.final_score(player.id) Settings.winning_score = 20 var winner_popup: WinnerPopup = _node("WinnerPopup") winner_popup.show_winner(players[2]) winner_popup.show_history(history) await _settle() await on_state.call("popup_WinnerWindow_long_game", winner_popup) winner_popup.close() await _settle() # Playing from a music folder: a soundtrack folder, then a prepared quiz in Settings var music: String = _make_music_folders() var settings_popup: SettingsPopup = _node("SettingsPopup") # Songs are picked at random; the same picks every time keep the pictures the same seed(1) settings_popup.use_music_folder(music.path_join("Soundtracks")) await _settle() _call("play_next_song") # A playing song is stopped first, which takes half a second await _main.get_tree().create_timer(0.7).timeout await _settle() _call("show_answer_pressed") await _settle() await on_state.call("offline", null) settings_popup.use_music_folder(music.path_join("Musiktävling")) settings_popup.open() await _settle() await on_state.call("popup_SettingsWindow_offline", settings_popup) settings_popup.close() # The end of a prepared quiz, where two players share the most points var scores: Array[int] = [7, 12, 12, 3, 9] for index: int in Settings.player_array.size(): Settings.player_array[index].player_score = scores[index % scores.size()] _call("show_final_winners") var final_popup: WinnerPopup = _node("WinnerPopup") await _settle() await on_state.call("popup_WinnerWindow_prepared_quiz", final_popup) final_popup.close() settings_popup.use_server() await _settle() # Writes a soundtrack folder and a prepared quiz with copies of the test song, and # returns the folder they are in static func _make_music_folders() -> String: var root: String = OS.get_user_data_dir().path_join("ui_tour_music") var song: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3") var files: Array[String] = [ "Soundtracks/Chrono Trigger/Corridors of Time.mp3", "Soundtracks/Final Fantasy VI/Dancing Mad.mp3", "Soundtracks/Secret of Mana/Into the Thick of It.mp3", "Musiktävling/01. Opening.mp3", "Musiktävling/02. Ace Combat 7 - Daredevil.mp3", ] for file: String in files: var path: String = root.path_join(file) DirAccess.make_dir_recursive_absolute(path.get_base_dir()) FileAccess.open(path, FileAccess.WRITE).store_buffer(song) return root ## Returns where every Control and Window in [param subtree] is on screen, keyed by its ## path from [param main]: [code]{"rect": [x, y, width, height], "visible": bool}[/code]. ## Nodes added at runtime without a name are keyed by their index, e.g. [code]#2[/code], ## so the keys are the same on every run. static func dump_layout(main: Node, subtree: Node) -> Dictionary: var layout: Dictionary = {} var path: String = String(main.get_path_to(subtree)) _dump_node(subtree, path, layout) return layout static func _dump_node(node: Node, path: String, layout: Dictionary) -> void: if node is Control: var rect: Rect2 = (node as Control).get_global_rect() layout[path] = {"rect": _round_rect(rect), "visible": node.is_visible_in_tree()} elif node is Window: var window: Window = node layout[path] = { "rect": _round_rect(Rect2(window.position, window.size)), "visible": window.visible, } for child: Node in node.get_children(): var key: String = "#%d" % child.get_index() if "@" in child.name \ else String(child.name) _dump_node(child, key if path == "." else path + "/" + key, layout) static func _round_rect(rect: Rect2) -> Array[float]: return [snappedf(rect.position.x, 0.01), snappedf(rect.position.y, 0.01), snappedf(rect.size.x, 0.01), snappedf(rect.size.y, 0.01)] ## Finds a node of the main window by [param node_name], whether it is a direct child ## of the main window or of its Quiz or TopBar scene. func _node(node_name: String) -> Node: for parent: Node in [_main, _main.get_node_or_null("Quiz"), _main.get_node_or_null("TopBar")]: if parent != null && parent.has_node(node_name): return parent.get_node(node_name) push_error("UiTour: no node named " + node_name) return null ## Calls [param method] on the players panel, the quiz or the main window, whichever ## has it first. func _call(method: String, args: Array = []) -> Variant: for path: String in ["Quiz/PlayersPanel", "Quiz"]: var target: Node = _main.get_node_or_null(path) if target != null && target.has_method(method): return target.callv(method, args) return _main.callv(method, args) # A blinking text cursor would be shown in some pictures and not in others func _stop_caret_blink(node: Node) -> void: if node is TextEdit || node is LineEdit: node.caret_blink = false for child: Node in node.get_children(): _stop_caret_blink(child) func _server() -> FakeHttpTransport: return MusicServerClient.transport func _settle() -> void: for i: int in 4: await _main.get_tree().process_frame func _add_player(player_name: String) -> void: (_node("PlayersPanel").get_node("VBoxContainer/AddPlayerContainer/PlayerNameField") \ as LineEdit).text = player_name _call("add_player") func _player_row(index: int) -> PlayerRow: return _node("PlayersPanel").get_node( "VBoxContainer/ScrollContainer/PlayersListVBoxContainer").get_child(index) ## Stops the song so the time and progress bar look the same on every run. func _freeze_music_player() -> void: var music_player: MusicPlayer = _node("MusicPlayer") music_player.audio_player.stop() music_player.sound_player.stop() music_player.progress_slider.value = 0 music_player.music_time_label.text = "00:00 / 00:01" # The buttons' animations would otherwise be caught halfway, see ButtonBounce for button: Button in [music_player.play_button, music_player.restart_button]: if button.has_meta(&"button_bounce_tween"): (button.get_meta(&"button_bounce_tween") as Tween).kill() button.scale = Vector2.ONE ## The log starts each row with the time of day; use a fixed time instead. func _fix_log_times() -> void: var log_popup: BasePopup = _node("LogPopup") for row: ListRow in log_popup.log_box.get_children(): row.label.text = "12:00:00 " + row.label.text.substr(9) func _open_popup(popup_name: String) -> Node: var popup: Node = _node(popup_name) match popup_name: "SyncPopup": # Opening the popup starts a sync; show the empty popup instead popup.open() "CharacterSelectPopup": popup.show_grid("Crono") "WinnerPopup": _show_winner_after_a_game(popup) _: popup.open() return popup ## Shows the Winner popup after a whole game, so the graph has something to show. The ## scores are put back afterwards by [method _close_popup]. func _show_winner_after_a_game(popup: WinnerPopup) -> void: _scores_before_win = [] for player: PlayerObject in Settings.player_array: _scores_before_win.append(player.player_score) var crono: PlayerObject = Settings.player_array[0] var marle: PlayerObject = Settings.player_array[1] var song_scorers: Array = [] for song: int in range(1, 17): var scorers: Array[String] = [] if song in [2, 4, 5, 10, 11, 12, 13, 16]: scorers.append(crono.id) if song in [1, 3, 10, 14, 15]: scorers.append(marle.id) song_scorers.append(scorers) crono.player_score = 8 marle.player_score = 5 _winning_score_before_win = Settings.winning_score Settings.winning_score = 8 popup.show_winner(crono) popup.show_history(ScoreHistory.from_songs(Settings.player_array, song_scorers)) func _close_popup(popup: Node) -> void: if popup is WinnerPopup: Settings.winning_score = _winning_score_before_win for i: int in _scores_before_win.size(): Settings.player_array[i].player_score = _scores_before_win[i] if popup is BasePopup: popup.close() else: popup.visible = false