- Types on every parameter, return value and variable, and snake_case names (the player name limit is the constant MAX_NAME_LENGTH). - Spelling fixes: *_triggered, character_selected, separator, caught_errors. The server's catched_errors key stays as the server sends it. - Signals are connected with signal.connect() instead of by name. - Code is ordered signals, constants, exported vars, vars, @onready vars, functions, with @onready on one line and two blank lines between functions. - Every script starts with a ## description. Template comments, old LOC notes and commented-out code are gone, apart from the local play code. - Lines are at most 100 characters, apart from the local play path. - The screenshot tour turns off the blinking text cursor so pictures match.
264 lines
8.9 KiB
GDScript
264 lines
8.9 KiB
GDScript
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 and a sync.
|
|
##
|
|
## 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", "Song": "Corridors of Time",
|
|
}
|
|
const SYNC_PROGRESS: Dictionary = {"progress": 40, "time_spent": "00: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:00:03",
|
|
}
|
|
|
|
## 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"],
|
|
["StatisticsPopupPanel", "StatisticsPopupPanel"],
|
|
]
|
|
|
|
var _main: Control
|
|
|
|
|
|
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("/characters", [])
|
|
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)
|
|
return server
|
|
|
|
|
|
## 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.edit_players = false
|
|
Settings.currently_syncing = false
|
|
Settings.character_select_open = false
|
|
Settings.add_to_stats = true
|
|
Settings.hide_next_track = true
|
|
Settings.use_low_played_mode = false
|
|
Settings.winning_score = 20
|
|
# 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:
|
|
# The debug build lowers the winning score on startup; keep it out of reach
|
|
Settings.winning_score = 20
|
|
_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()
|
|
_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 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()
|
|
|
|
|
|
## 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 TextEdit).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 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: Label in log_popup.log_box.get_children():
|
|
row.text = "12:00:00 " + row.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":
|
|
popup.show_winner(Settings.player_array[0])
|
|
"StatisticsPopupPanel":
|
|
_call("get_statistics")
|
|
_:
|
|
popup.open()
|
|
return popup
|
|
|
|
|
|
func _close_popup(popup: Node) -> void:
|
|
if popup is BasePopup:
|
|
popup.close()
|
|
else:
|
|
popup.visible = false
|