132646f6c3
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>
141 lines
6.1 KiB
GDScript
141 lines
6.1 KiB
GDScript
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])
|