Rework the UI on Crusenho's pack; credit it in the game, not just the repo
ci / verify (push) Successful in 48s
ci / verify (push) Successful in 48s
Crusenho's Complete UI Essential Pack is CC BY 4.0 -- redistributable and
commercial-friendly, confirmed from the License.txt the pack itself ships --
so unlike the two bdragon packs a subset is committed: twelve PNGs, 48 KB,
under assets/sprites/ui/. Only what is used, because each committed PNG costs
a Godot .import sidecar and a directory nothing references is one nobody
prunes.
UiTheme builds a Theme in code from it -- button states, panels, line edits --
and every screen roots itself through UiTheme.themed_root(). The HUD's bars are
the pack's frame with a tinted fill, drawn as three horizontal slices because
Godot's nine-patch lives on nodes and the HUD is drawn rather than built from
controls. Inventory slots use the pack's slot art at exactly twice the source
size; a non-integer scale on a 1px border reads as a wobble along every edge.
The credits screen is the other half of the request and it is a licence
obligation, not a nicety: two packs are now CC BY, which asks for attribution
"in any reasonable manner", and a markdown file in a source repo is not
reasonable for someone who downloaded a build. Settings -> Credits shows every
source with its terms and a link to the licence text. test_credits.gd asserts
CREDITS.md and docs/ASSETS.md name every entry, so the three cannot drift.
Two things found by actually looking at the screen, which is the point:
- The FIRST version of this styled nothing. A Control inherits its theme from
Control ANCESTORS only, and the chain breaks at the first plain Node or
CanvasLayer -- which is every screen here. get_window().theme set the
property, changed nothing, and read as correct. check.sh, 458 tests and a
clean smoke run all passed with the entire interface unstyled. The theme
test now instantiates every screen and asks what its buttons resolve.
- The settings screen showed Fire bound to the right mouse button, because
the test suite was writing the player's real user://settings.cfg --
rebinding calls save() and nothing had redirected the path. Settings.path
is now redirectable, the fixture points it at a scratch file, and a test
asserts the default is still the player's own.
tools/screenshot.tscn is what found both. It boots the client windowed and
saves the menus, the HUD, settings and credits. Manual, needs a display, and
the only thing in the project that can tell you the interface rendered.
check.sh clean, 460 tests, SMOKE PASS, all four diagnostics green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
extends GutTest
|
||||
## Attribution. Two of the packs this game ships are CC BY 4.0, which requires
|
||||
## credit -- so these are licence-compliance tests, not tidiness ones.
|
||||
|
||||
|
||||
func test_every_source_is_completely_described() -> void:
|
||||
for entry in Credits.SOURCES:
|
||||
for field in ["name", "author", "licence", "url", "used_for"]:
|
||||
assert_false(String(entry[field]).strip_edges().is_empty(),
|
||||
"%s has no %s" % [entry.get("name", "?"), field])
|
||||
assert_true(String(entry["url"]).begins_with("http"),
|
||||
"%s has no usable link" % entry["name"])
|
||||
|
||||
|
||||
## The whole point of the flag: it marks the entries a licence obliges us to
|
||||
## show, as opposed to the ones credited because the work deserves it.
|
||||
func test_the_cc_by_packs_are_marked_as_required() -> void:
|
||||
var required := Credits.required()
|
||||
assert_gt(required.size(), 0)
|
||||
for entry in Credits.SOURCES:
|
||||
var is_cc_by := String(entry["licence"]).begins_with("CC BY")
|
||||
assert_eq(bool(entry["required"]), is_cc_by,
|
||||
"%s is %s but marked required=%s" % [
|
||||
entry["name"], entry["licence"], entry["required"]])
|
||||
|
||||
|
||||
## CC BY asks for a link to the licence itself, not only to the work. A player
|
||||
## reading the in-game screen has no other way to reach the terms.
|
||||
func test_every_creative_commons_licence_links_to_its_terms() -> void:
|
||||
for entry in Credits.SOURCES:
|
||||
var licence := String(entry["licence"])
|
||||
if not licence.begins_with("CC"):
|
||||
continue
|
||||
assert_true(Credits.licence_url(licence).begins_with(
|
||||
"https://creativecommons.org/"),
|
||||
"%s has no link to its terms" % licence)
|
||||
|
||||
|
||||
## The repository file and the in-game screen have to say the same thing. They
|
||||
## are maintained separately, so this is what stops one of them going stale --
|
||||
## and a stale attribution is a licence problem, not a documentation one.
|
||||
func test_the_credits_file_names_every_source() -> void:
|
||||
var f := FileAccess.open("res://CREDITS.md", FileAccess.READ)
|
||||
assert_not_null(f, "CREDITS.md should be readable")
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
for entry in Credits.SOURCES:
|
||||
assert_true(text.contains(String(entry["name"])),
|
||||
"CREDITS.md never mentions %s" % entry["name"])
|
||||
assert_true(text.contains(String(entry["author"])),
|
||||
"CREDITS.md never credits %s" % entry["author"])
|
||||
assert_true(text.contains(String(entry["url"])),
|
||||
"CREDITS.md has no link for %s" % entry["name"])
|
||||
|
||||
|
||||
func test_the_asset_document_covers_every_source_too() -> void:
|
||||
var f := FileAccess.open("res://docs/ASSETS.md", FileAccess.READ)
|
||||
assert_not_null(f)
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
for entry in Credits.SOURCES:
|
||||
assert_true(text.contains(String(entry["url"])),
|
||||
"docs/ASSETS.md has no entry for %s" % entry["name"])
|
||||
|
||||
|
||||
## Every pack in the repository's own asset directories must be accounted for.
|
||||
## Committing art without recording where it came from is how a project ends up
|
||||
## unable to say whether it may ship.
|
||||
func test_the_committed_ui_art_is_attributed() -> void:
|
||||
assert_true(DirAccess.dir_exists_absolute("res://assets/sprites/ui"),
|
||||
"the UI art should be committed")
|
||||
var names := ""
|
||||
for entry in Credits.SOURCES:
|
||||
names += String(entry["name"])
|
||||
assert_true(names.contains("Complete UI Essential Pack"),
|
||||
"the UI pack has to be credited before its art is shipped")
|
||||
@@ -0,0 +1 @@
|
||||
uid://tb74xt0r3x5s
|
||||
@@ -8,6 +8,7 @@ const SCRATCH := "user://test_settings_%d.cfg"
|
||||
var _saved_bindings: Dictionary[String, Dictionary] = {}
|
||||
var _saved_master: float = 0.0
|
||||
var _saved_sfx: float = 0.0
|
||||
var _scratch: String = ""
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
@@ -16,6 +17,12 @@ func before_each() -> void:
|
||||
_saved_bindings = Settings.bindings.duplicate()
|
||||
_saved_master = Settings.master_volume
|
||||
_saved_sfx = Settings.sfx_volume
|
||||
# And rebinding SAVES. Without redirecting the file, running the suite
|
||||
# rewrote the player's own settings -- it really did, and it took a
|
||||
# screenshot of the settings screen showing Fire on the right mouse button
|
||||
# to notice.
|
||||
_scratch = SCRATCH % randi()
|
||||
Settings.path = _scratch
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
@@ -23,6 +30,8 @@ func after_each() -> void:
|
||||
Settings.master_volume = _saved_master
|
||||
Settings.sfx_volume = _saved_sfx
|
||||
Settings.apply_input()
|
||||
Settings.path = Settings.DEFAULT_PATH
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(_scratch))
|
||||
|
||||
|
||||
func _key_event(code: Key) -> InputEventKey:
|
||||
@@ -226,3 +235,22 @@ func test_a_settings_file_missing_keys_loads_without_complaint() -> void:
|
||||
# default, which is what made this log an engine error per missing action.
|
||||
assert_false(loaded.has_section_key("input", "move_up"))
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
|
||||
|
||||
## The redirect above is a test fixture. If it were ever left in place -- or the
|
||||
## default changed to something under a scratch directory -- players would
|
||||
## silently stop keeping their settings.
|
||||
func test_the_default_path_is_the_players_own_file() -> void:
|
||||
assert_eq(Settings.DEFAULT_PATH, "user://settings.cfg")
|
||||
assert_true(Settings.DEFAULT_PATH.begins_with("user://"))
|
||||
|
||||
|
||||
## Saving has to actually reach the file the path points at, or the redirect
|
||||
## above would hide a broken save rather than isolate a working one.
|
||||
func test_saving_writes_to_the_configured_path() -> void:
|
||||
Settings.master_volume = 0.33
|
||||
Settings.save()
|
||||
assert_true(FileAccess.file_exists(_scratch), "nothing was written")
|
||||
var cfg := ConfigFile.new()
|
||||
assert_eq(cfg.load(_scratch), OK)
|
||||
assert_almost_eq(float(cfg.get_value("audio", "master")), 0.33, 0.001)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
extends GutTest
|
||||
## The control theme. A missing texture gives a null and a silently unstyled
|
||||
## control, which looks exactly like the theme never having been applied -- so
|
||||
## every path is checked rather than assumed.
|
||||
|
||||
|
||||
func test_every_declared_texture_loads() -> void:
|
||||
for key in UiTheme.TEXTURES:
|
||||
var tex := UiTheme.texture(key)
|
||||
assert_not_null(tex, "%s (%s) did not load" % [key, UiTheme.TEXTURES[key]])
|
||||
assert_gt(tex.get_width(), 0)
|
||||
assert_gt(tex.get_height(), 0)
|
||||
|
||||
|
||||
func test_the_theme_styles_the_controls_the_game_actually_builds() -> void:
|
||||
var t := UiTheme.get_theme()
|
||||
for state in ["normal", "hover", "pressed", "disabled", "focus"]:
|
||||
assert_true(t.has_stylebox(state, "Button"), "Button has no %s style" % state)
|
||||
assert_true(t.has_stylebox("panel", "Panel"))
|
||||
assert_true(t.has_stylebox("panel", "PanelContainer"))
|
||||
assert_true(t.has_stylebox("normal", "LineEdit"))
|
||||
|
||||
|
||||
## The pack's surfaces are cream with a black outline, so text on them has to be
|
||||
## dark. Everything the game draws over the world stays light; only controls
|
||||
## sitting on this art flip.
|
||||
func test_text_on_the_packs_surfaces_is_dark() -> void:
|
||||
var t := UiTheme.get_theme()
|
||||
for key in ["font_color", "font_hover_color", "font_pressed_color"]:
|
||||
var c: Color = t.get_color(key, "Button")
|
||||
assert_lt(c.get_luminance(), 0.35, "%s is too light for cream art" % key)
|
||||
assert_lt((t.get_color("font_color", "LineEdit") as Color).get_luminance(), 0.35)
|
||||
|
||||
|
||||
## Nine-patch margins are what stop a 1px border smearing across a stretched
|
||||
## button. Zero margins would render as a plain stretched texture.
|
||||
func test_button_styles_are_nine_patched() -> void:
|
||||
var t := UiTheme.get_theme()
|
||||
for state in ["normal", "hover", "pressed", "disabled"]:
|
||||
var sb := t.get_stylebox(state, "Button") as StyleBoxTexture
|
||||
assert_not_null(sb)
|
||||
for side in [SIDE_LEFT, SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM]:
|
||||
assert_gt(sb.get_texture_margin(side), 0.0,
|
||||
"%s has no stretch margin on side %d" % [state, side])
|
||||
|
||||
|
||||
## The corner size cannot exceed half the sprite, or the two caps overlap and
|
||||
## the middle has negative width.
|
||||
func test_stretch_margins_fit_inside_their_sprites() -> void:
|
||||
var t := UiTheme.get_theme()
|
||||
for entry in [["normal", "Button"], ["panel", "Panel"], ["normal", "LineEdit"]]:
|
||||
var sb := t.get_stylebox(entry[0], entry[1]) as StyleBoxTexture
|
||||
var size := Vector2(sb.texture.get_size())
|
||||
assert_lte(sb.get_texture_margin(SIDE_LEFT) + sb.get_texture_margin(SIDE_RIGHT),
|
||||
size.x, "%s margins exceed its width" % entry[1])
|
||||
assert_lte(sb.get_texture_margin(SIDE_TOP) + sb.get_texture_margin(SIDE_BOTTOM),
|
||||
size.y, "%s margins exceed its height" % entry[1])
|
||||
|
||||
|
||||
## Built once and shared. Themes are read-only data, so a second instance would
|
||||
## be waste rather than isolation.
|
||||
func test_the_theme_is_built_once() -> void:
|
||||
assert_eq(UiTheme.get_theme(), UiTheme.get_theme())
|
||||
|
||||
|
||||
## The four button states have to be four different pictures, or the interface
|
||||
## gives no feedback and the pack's state art is going unused.
|
||||
func test_the_button_states_are_visually_distinct() -> void:
|
||||
var seen := {}
|
||||
for key in ["button", "button_hover", "button_pressed", "button_disabled"]:
|
||||
seen[UiTheme.TEXTURES[key]] = true
|
||||
assert_eq(seen.size(), 4, "two button states share a texture")
|
||||
|
||||
|
||||
## The inventory draws slots at exactly twice the source size. A non-integer
|
||||
## scale on pixel art with nearest filtering gives uneven pixel widths, which on
|
||||
## a 1px border reads as a wobble along every edge.
|
||||
func test_the_inventory_slot_is_drawn_at_a_whole_multiple() -> void:
|
||||
var slot := UiTheme.texture("slot")
|
||||
var scale := HUD_SLOT / float(slot.get_width())
|
||||
assert_eq(scale, floorf(scale), "slot art is scaled by %f" % scale)
|
||||
assert_eq(slot.get_width(), slot.get_height(), "the slot should be square")
|
||||
|
||||
|
||||
const HUD_SLOT := 64.0
|
||||
|
||||
|
||||
## A Control resolves its theme by walking up the parent chain, which is why
|
||||
## one assignment near the root covers screens that share no parent. Checked on
|
||||
## the mechanism rather than on a Window, so it runs headless.
|
||||
func test_the_theme_reaches_controls_through_their_ancestors() -> void:
|
||||
var root: Control = autofree(Control.new())
|
||||
root.theme = UiTheme.get_theme()
|
||||
var middle := Control.new()
|
||||
root.add_child(middle)
|
||||
var button := Button.new()
|
||||
middle.add_child(button)
|
||||
assert_eq(button.get_theme_stylebox("normal", "Button"),
|
||||
UiTheme.get_theme().get_stylebox("normal", "Button"),
|
||||
"a button two levels down should still find the theme")
|
||||
assert_eq(button.get_theme_color("font_color", "Button"), UiTheme.INK)
|
||||
|
||||
|
||||
func test_applying_to_nothing_is_harmless() -> void:
|
||||
UiTheme.apply_to(null)
|
||||
assert_true(true, "a null root must not be a crash")
|
||||
|
||||
|
||||
func test_a_themed_root_fills_its_parent_and_carries_the_theme() -> void:
|
||||
var root: Control = autofree(UiTheme.themed_root())
|
||||
assert_eq(root.theme, UiTheme.get_theme())
|
||||
assert_eq(root.anchor_right, 1.0)
|
||||
assert_eq(root.anchor_bottom, 1.0)
|
||||
|
||||
|
||||
## Every screen in the game is built in code and hangs off either a plain Node
|
||||
## or a CanvasLayer, and theme inheritance travels Control-to-Control ONLY --
|
||||
## the chain breaks at the first non-Control parent. Setting the theme once on
|
||||
## the Window therefore looked like it worked and styled nothing, which is a
|
||||
## mistake that is invisible until somebody looks at the screen. So: every
|
||||
## screen is instantiated here and asked what its buttons actually resolve.
|
||||
func test_every_screen_actually_gets_the_theme() -> void:
|
||||
var screens := {
|
||||
"HUD": "res://src/ui/hud.gd",
|
||||
"GameMenu": "res://src/ui/game_menu.gd",
|
||||
"CharacterSelect": "res://src/ui/character_select.gd",
|
||||
"UpgradeScreen": "res://src/ui/upgrade_screen.gd",
|
||||
"SettingsScreen": "res://src/ui/settings_screen.gd",
|
||||
"CreditsScreen": "res://src/ui/credits_screen.gd",
|
||||
"MainMenu": "res://src/ui/main_menu.gd",
|
||||
}
|
||||
var wanted := UiTheme.get_theme().get_stylebox("normal", "Button")
|
||||
for name in screens:
|
||||
var node: Node = (load(screens[name]) as Script).new()
|
||||
add_child_autofree(node)
|
||||
var buttons := node.find_children("*", "Button", true, false)
|
||||
assert_gt(buttons.size(), 0, "%s has no button to check" % name)
|
||||
for b: Button in buttons:
|
||||
assert_eq(b.get_theme_stylebox("normal", "Button"), wanted,
|
||||
"%s: '%s' is not themed" % [name, b.text])
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddwfq3jpwgfep
|
||||
Reference in New Issue
Block a user