Put the whole look into one project theme
Build / test (pull_request) Successful in 31s
Build / build (pull_request) Skipped
Build / test (push) Successful in 31s
Build / build (push) Successful in 1m13s

themes/main_theme.tres is the project theme. It holds Godot's default look for
every control type the game uses, plus named styles for the game's own look
(popups, the background, spacers, list rows, title and text sizes, highlights)
and a palette for the popup dimming. Scenes and scripts name these styles
instead of setting colours, sizes or style boxes, and the five old theme files
are gone. themes/README.md explains the styles and how to make a new theme.

The screenshot tour now uses the real character pictures, so the layout
baseline is taken again. Screenshots are pixel-identical to before, and
test_theme.gd checks that the look stays in the theme.
This commit was merged in pull request #36.
This commit is contained in:
Sansan committed 2026-10-06 13:33:35 +02:00
1 parent a7bf38c5f9
commit 8b71723a34
39 files changed
+3283 -1869

No files matched your search

File diff suppressed because it is too large. Load diff
+22 -1
View File
@@ -27,6 +27,9 @@ const SYNC_RESULT: Dictionary = {
"total_time": "00:00:03",
}
## The character pictures the fake server hands out.
const CHARACTERS_DIR: String = "res://characters"
## 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.
@@ -53,7 +56,11 @@ static func make_server() -> FakeHttpTransport:
server.respond_json("/music/all", SOUNDTRACKS)
server.respond_json("/music/all/order", SOUNDTRACKS)
server.respond_json("/music/list", [])
server.respond_json("/characters", [])
var characters: Array[String] = character_names()
server.respond_json("/characters", 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)
@@ -64,6 +71,16 @@ static func make_server() -> FakeHttpTransport:
return server
## 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:
@@ -104,6 +121,10 @@ func run(on_state: Callable) -> void:
_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))
_player_row(0).add_point()
_player_row(0).add_point()
_player_row(1).add_point()
+9 -84
View File
@@ -1,73 +1,14 @@
extends GdUnitTestSuite
## Checks that every node of the main window is where it was before the window was
## split into scenes.
## Checks that every node of the main window is where it was when the baseline was taken.
##
## [code]tests/data/layout_baseline.json[/code] was written by
## [code]tools/take_screenshots.gd[/code] before the split. It holds the position, size
## and visibility of every node in each UiTour state, keyed by the node's old path. This
## test runs the same tour and compares, after moving each old path to where the node
## lives now.
## [code]tests/data/layout_baseline.json[/code] is written by
## [code]tools/take_screenshots.gd[/code] (run it with --headless and copy its layout.json).
## It holds the position, size and visibility of every node in each UiTour state, keyed
## by the node's path from the main window. Take a new baseline when the layout is
## meant to change, e.g. after moving a button.
const BASELINE_PATH: String = "res://tests/data/layout_baseline.json"
## Where each node of the old main window lives now: its old name, and the path that
## replaces it. Nodes that are not listed are still where they were.
const MOVED: Dictionary = {
# The top bar
"TopPanelContainer": "TopBar/TopPanelContainer",
"Title": "TopBar/Title",
"SearchButton": "TopBar/SearchButton",
"SyncButton": "TopBar/SyncButton",
"StatisticsButton": "TopBar/StatisticsButton",
"ShortcutsButton": "TopBar/ShortcutsButton",
"AboutButton": "TopBar/AboutButton",
"QRButton": "TopBar/QRButton",
# The popups of the main window, renamed from *Window to *Popup
"Search": "SearchPopup",
"SyncWindow": "SyncPopup",
"QRWindow": "QRPopup",
"ShortcutsWindow": "ShortcutsPopup",
"AboutWindow": "AboutPopup",
"Log": "LogPopup",
# The quiz and its parts
"Open": "Quiz/Open",
"FileDialog": "Quiz/FileDialog",
"DebugLabel": "Quiz/DebugLabel",
"MusicListPanel": "Quiz/MusicListPanel",
"Players": "Quiz/PlayersPanel",
"ScrollContainer": "Quiz/InspirationList",
"VBoxContainer": "Quiz/NowPlaying",
"MusicPlayer": "Quiz/MusicPlayer/PlayerBar",
"RepeatSongCheckButton": "Quiz/MusicPlayer/RepeatSongCheckButton",
"ResetPlaylistButton": "Quiz/QuizToolbar/ResetPlaylistButton",
"ResetPointsButton": "Quiz/QuizToolbar/ResetPointsButton",
"SettingsButton": "Quiz/QuizToolbar/SettingsButton",
"SoundTestButton": "Quiz/QuizToolbar/SoundTestButton",
"ShowAnswerButton": "Quiz/SongControls/ShowAnswerButton",
"NextButton": "Quiz/SongControls/NextButton",
"SettingsWindow": "Quiz/SettingsPopup",
"NewCharacterSelect": "Quiz/CharacterSelectPopup",
"WinnerWindow": "Quiz/WinnerPopup",
}
## Scenes that were renamed, by their old and new root name. A row added to a list is
## named after its scene's root when the name is still free, so these show up deeper in a
## path.
const RENAMED_SCENES: Dictionary = {
"SongListObject": "SongRow",
"Player": "PlayerRow",
}
const SYNC_COLUMNS: String = "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer"
## Nodes renamed inside a popup: the path after the moves above, and the new path.
const RENAMED_PATHS: Dictionary = {
"SearchPopup/ScrollContainer/VBoxContainer": "SearchPopup/ScrollContainer/SearchResults",
SYNC_COLUMNS + "/CatchedErrorsVBox": SYNC_COLUMNS + "/CaughtErrorsVBox",
SYNC_COLUMNS + "/CaughtErrorsVBox/CatchedErrorsLabel":
SYNC_COLUMNS + "/CaughtErrorsVBox/CaughtErrorsLabel",
}
## Nodes whose height follows text that changes between versions, such as the changelog.
## Their position and width are still checked.
const HEIGHT_FOLLOWS_TEXT: Array[String] = [
@@ -105,21 +46,6 @@ func after() -> void:
_snapshot.restore()
## Where the node that used to be at [param old_path] in the main window is now.
static func new_path(old_path: String) -> String:
var parts: PackedStringArray = old_path.split("/")
for i: int in parts.size():
parts[i] = RENAMED_SCENES.get(parts[i], parts[i])
var first: String = parts[0]
parts[0] = MOVED.get(first, first)
var path: String = "/".join(parts)
# In order, so a node inside a renamed node is found after its parent's rename
for renamed: String in RENAMED_PATHS:
if path == renamed || path.begins_with(renamed + "/"):
path = RENAMED_PATHS[renamed] + path.trim_prefix(renamed)
return path
func _baseline() -> Dictionary:
return JSON.parse_string(FileAccess.get_file_as_string(BASELINE_PATH))
@@ -133,11 +59,10 @@ func test_every_node_is_where_it_was() -> void:
var baseline: Dictionary = _baseline()
for state: String in baseline:
var actual: Dictionary = _layout.get(state, {})
for old_path: String in baseline[state]:
var path: String = new_path(old_path)
var expected: Dictionary = baseline[state][old_path]
for path: String in baseline[state]:
var expected: Dictionary = baseline[state][path]
if !actual.has(path):
problems.append("%s: %s is missing (was %s)" % [state, path, old_path])
problems.append("%s: %s is missing" % [state, path])
continue
var rect: Array = actual[path].rect
var sides: int = 3 if _height_follows_text(path) else 4
+1 -1
View File
@@ -5,7 +5,7 @@ extends MainWindowTestSuite
## server and the keyboard shortcuts reach both the main window and the quiz.
const MAIN_WINDOW_CHILDREN: Array[String] = [
"TopBar", "Quiz", "PopupBackdrop", "SearchPopup", "SyncPopup", "QRPopup",
"Background", "TopBar", "Quiz", "PopupBackdrop", "SearchPopup", "SyncPopup", "QRPopup",
"ShortcutsPopup", "StatisticsPopupPanel", "AboutPopup", "LogPopup",
]
+117
View File
@@ -0,0 +1,117 @@
extends GdUnitTestSuite
## The whole look comes from the project theme, themes/main_theme.tres: scenes and
## scripts name its styles instead of setting colours, sizes or style boxes themselves.
const THEME_PATH: String = "res://themes/main_theme.tres"
## Folders with the game's scenes and scripts.
const GAME_FOLDERS: Array[String] = [
"res://main", "res://quiz", "res://player", "res://popups", "res://common", "res://data",
"res://server",
]
## The named styles the game uses, and the type each one is based on.
const STYLES: Dictionary = {
"WindowBackground": "Panel",
"PopupBackground": "Panel",
"DarkSpacer": "Panel",
"CharacterCard": "Panel",
"MusicList": "PanelContainer",
"SongRow": "PanelContainer",
"ShortcutRowDark": "PanelContainer",
"ListRowEven": "PanelContainer",
"ListRowOdd": "PanelContainer",
"TitleLarge": "Label",
"TitleMedium": "Label",
"Title": "Label",
"TextLarge": "Label",
"TextMedium": "Label",
"HighlightLarge": "Label",
"HighlightMedium": "Label",
"SongRowLabel": "Label",
"SongRowLabelPlaying": "Label",
}
var _theme: Theme = load(THEME_PATH)
## Every file in [param folder] and its subfolders whose name ends with [param extension].
func _files(folder: String, extension: String) -> Array[String]:
var found: Array[String] = []
for file_name: String in DirAccess.get_files_at(folder):
if file_name.ends_with(extension):
found.append(folder.path_join(file_name))
for subfolder: String in DirAccess.get_directories_at(folder):
found.append_array(_files(folder.path_join(subfolder), extension))
return found
func _game_files(extension: String) -> Array[String]:
var found: Array[String] = []
for folder: String in GAME_FOLDERS:
found.append_array(_files(folder, extension))
return found
func _lines_matching(files: Array[String], pattern: String) -> Array[String]:
var regex: RegEx = RegEx.create_from_string(pattern)
var hits: Array[String] = []
for path: String in files:
var lines: PackedStringArray = FileAccess.get_file_as_string(path).split("\n")
for i: int in lines.size():
if regex.search(lines[i]) != null:
hits.append("%s:%d: %s" % [path, i + 1, lines[i].strip_edges()])
return hits
func test_project_uses_the_main_theme() -> void:
assert_str(ProjectSettings.get_setting("gui/theme/custom")).is_equal(THEME_PATH)
func test_theme_has_every_named_style() -> void:
for style: String in STYLES:
assert_str(String(_theme.get_type_variation_base(style))) \
.override_failure_message(style + " is missing or has the wrong base") \
.is_equal(STYLES[style])
func test_theme_has_the_palette() -> void:
assert_bool(_theme.has_color("backdrop", "Palette")).is_true()
func test_every_style_named_in_scenes_and_scripts_exists() -> void:
var regex: RegEx = RegEx.create_from_string('&"([A-Z][A-Za-z]+)"')
var missing: Array[String] = []
for path: String in _game_files(".tscn") + _game_files(".gd"):
for found: RegExMatch in regex.search_all(FileAccess.get_file_as_string(path)):
var style: String = found.get_string(1)
if !(style in STYLES) && style != "Palette":
missing.append("%s: %s" % [path, style])
assert_array(missing).override_failure_message(
"Styles not in the theme:\n" + "\n".join(missing)).is_empty()
func test_scenes_take_their_look_from_the_theme() -> void:
var hits: Array[String] = _lines_matching(_game_files(".tscn"),
"^(theme = |label_settings = |theme_override_(styles|colors|font_sizes|fonts)/)" \
+ "|^\\[sub_resource type=\"(StyleBoxFlat|LabelSettings|Theme)\"")
assert_array(hits).override_failure_message(
"Look set in a scene instead of the theme:\n" + "\n".join(hits)).is_empty()
func test_scripts_take_their_look_from_the_theme() -> void:
var hits: Array[String] = _lines_matching(_game_files(".gd"),
"add_theme_[a-z_]+_override|StyleBoxFlat\\.new|Color\\(")
assert_array(hits).override_failure_message(
"Look set in a script instead of the theme:\n" + "\n".join(hits)).is_empty()
func test_main_window_background_comes_from_the_theme_and_lets_clicks_through() -> void:
var main: Control = auto_free(load("res://main/MainWindow.tscn").instantiate())
var background: Panel = main.get_child(0)
assert_str(background.name).is_equal("Background")
assert_str(String(background.theme_type_variation)).is_equal("WindowBackground")
assert_int(background.mouse_filter).is_equal(Control.MOUSE_FILTER_IGNORE)
assert_float(background.anchor_right).is_equal(1.0)
assert_float(background.anchor_bottom).is_equal(1.0)
+1
View File
@@ -0,0 +1 @@
uid://b2wva7kaaq8ua
+12 -5
View File
@@ -6,22 +6,29 @@ func _background(list_row: ListRow) -> Color:
return (list_row.get_theme_stylebox("panel") as StyleBoxFlat).bg_color
## Adds [param list_row] to the tree, where it gets its look from the project theme.
func _shown(list_row: ListRow) -> ListRow:
add_child(auto_free(list_row))
return list_row
func test_row_shows_text() -> void:
var list_row: ListRow = auto_free(ListRow.create("Chrono Trigger", 0, 20))
var list_row: ListRow = auto_free(ListRow.create("Chrono Trigger", 0, &"TextLarge"))
assert_str(list_row.text).is_equal("Chrono Trigger")
func test_neighbouring_rows_have_different_backgrounds() -> void:
var first: ListRow = auto_free(ListRow.create("Chrono Trigger", 0, 20))
var second: ListRow = auto_free(ListRow.create("Chrono Cross", 1, 20))
var third: ListRow = auto_free(ListRow.create("Xenogears", 2, 20))
var first: ListRow = _shown(ListRow.create("Chrono Trigger", 0, &"TextLarge"))
var second: ListRow = _shown(ListRow.create("Chrono Cross", 1, &"TextLarge"))
var third: ListRow = _shown(ListRow.create("Xenogears", 2, &"TextLarge"))
assert_that(_background(first)).is_not_equal(_background(second))
assert_that(_background(first)).is_equal(_background(third))
func test_long_text_wraps() -> void:
var list_row: ListRow = auto_free(ListRow.create("Castlevania: Symphony of the Night", 0, 20))
var list_row: ListRow = auto_free(
ListRow.create("Castlevania: Symphony of the Night", 0, &"TextLarge"))
assert_int(list_row.label.autowrap_mode).is_equal(TextServer.AUTOWRAP_WORD_SMART)