Rework the UI on Crusenho's pack; credit it in the game, not just the repo
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:
claudeandClaude Opus 5 committed 2026-09-07 11:12:13 +02:00
1 parent 872922e9e2
commit 132646f6c3
54 files changed
+1319 -50

No files matched your search

+3
View File
@@ -10,6 +10,7 @@ extends Node2D
@onready var characters: CanvasLayer = $CharacterSelect
@onready var upgrades: CanvasLayer = $UpgradeScreen
@onready var settings: CanvasLayer = $SettingsScreen
@onready var credits: CanvasLayer = $CreditsScreen
var _bound: ClientRuntime = null
## Opened deliberately from the menu, as opposed to forced open by having no
@@ -37,6 +38,8 @@ func _ready() -> void:
upgrades.closed.connect(func() -> void: _upgrades_open = false)
menu.settings_requested.connect(func() -> void: settings.open())
settings.closed.connect(func() -> void: settings.visible = false)
settings.credits_requested.connect(func() -> void: credits.open())
credits.closed.connect(func() -> void: credits.visible = false)
upgrades.choose_requested.connect(func(i: int) -> void: Net.choose_upgrade(i))
+116
View File
@@ -0,0 +1,116 @@
class_name UiTheme
extends RefCounted
## The game's control theme, built in code from Crusenho's UI pack.
##
## Built rather than authored as a .tres for the same reason [Content] is code:
## it is a readable diff, there are no resource UIDs churning in version
## control, and a test can build it without the editor.
##
## Applied to each screen's root Control ([method apply_to]). It cannot be
## applied once to the Window instead: Godot's theme lookup walks a Control's
## ancestors, and the chain BREAKS at the first plain Node -- which for every
## screen here is `main.gd` or a CanvasLayer. Setting `window.theme` looked like
## it worked and changed nothing.
const DIR := "res://assets/sprites/ui/"
## Every texture the theme needs. Named so a test can assert they all load: a
## missing file gives a null texture and a silently unstyled control, which
## looks like the theme never being applied at all.
const TEXTURES := {
"button": DIR + "Button01a_1.png",
"button_hover": DIR + "Button01a_2.png",
"button_pressed": DIR + "Button01a_3.png",
"button_disabled": DIR + "Button01a_4.png",
"panel": DIR + "Frame01a.png",
"panel_accent": DIR + "Frame02a.png",
"slot": DIR + "FrameSlot01a.png",
"slot_active": DIR + "FrameSlot01b.png",
"bar": DIR + "Bar01a.png",
"bar_fill": DIR + "BarFill01f.png",
"field": DIR + "InputField01a.png",
"banner": DIR + "Banner01a.png",
}
## The art is cream with a black outline, so text on it has to be dark. Every
## label the game draws over the dark world stays light; only controls that sit
## on this pack's own surfaces flip.
const INK := Color(0.12, 0.11, 0.13)
const INK_DIM := Color(0.42, 0.40, 0.44)
static var _theme: Theme = null
static func texture(key: String) -> Texture2D:
return load(TEXTURES[key]) as Texture2D
## One shared instance. Themes are pure data and every control only reads them,
## so building a second would be waste rather than isolation.
static func get_theme() -> Theme:
if _theme != null:
return _theme
var t := Theme.new()
t.default_font_size = 14
t.set_stylebox("normal", "Button", _box("button", 8, 12, 7))
t.set_stylebox("hover", "Button", _box("button_hover", 8, 12, 7))
t.set_stylebox("pressed", "Button", _box("button_pressed", 8, 12, 7))
t.set_stylebox("disabled", "Button", _box("button_disabled", 8, 12, 7))
# Focus deliberately reuses the hover art rather than adding an outline:
# the pack has no focus state, and an invented one would be the only thing
# on screen that is not from the set.
t.set_stylebox("focus", "Button", _box("button_hover", 8, 12, 7))
t.set_color("font_color", "Button", INK)
t.set_color("font_hover_color", "Button", INK)
t.set_color("font_pressed_color", "Button", INK)
t.set_color("font_disabled_color", "Button", INK_DIM)
t.set_color("font_focus_color", "Button", INK)
t.set_stylebox("panel", "Panel", _box("panel", 8, 10, 10))
t.set_stylebox("panel", "PanelContainer", _box("panel", 8, 10, 10))
t.set_stylebox("normal", "LineEdit", _box("field", 8, 10, 6))
t.set_stylebox("focus", "LineEdit", _box("field", 8, 10, 6))
t.set_color("font_color", "LineEdit", INK)
t.set_color("font_placeholder_color", "LineEdit", INK_DIM)
t.set_color("caret_color", "LineEdit", INK)
_theme = t
return _theme
## Give [param root] the theme. Everything under it inherits, so one call per
## screen covers that screen.
##
## Per-screen rather than once on the Window, because theme inheritance only
## travels Control-to-Control: a Control whose parent is a plain Node or a
## CanvasLayer resolves against the default theme however the Window is set.
## Every screen in this game hangs off one or the other.
static func apply_to(root: Control) -> void:
if root != null:
root.theme = get_theme()
## A full-rect Control with the theme already on it -- the root every screen
## built in code starts from. Using this rather than a bare Control is what
## stops a new screen quietly rendering unstyled.
static func themed_root() -> Control:
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
apply_to(root)
return root
## A nine-patch stylebox. [param margin] is the corner size in source pixels;
## the middle is what stretches.
static func _box(key: String, margin: int, pad_x: int, pad_y: int) -> StyleBoxTexture:
var sb := StyleBoxTexture.new()
sb.texture = texture(key)
for side in [SIDE_LEFT, SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM]:
sb.set_texture_margin(side, float(margin))
sb.set_content_margin(SIDE_LEFT, float(pad_x))
sb.set_content_margin(SIDE_RIGHT, float(pad_x))
sb.set_content_margin(SIDE_TOP, float(pad_y))
sb.set_content_margin(SIDE_BOTTOM, float(pad_y))
return sb
+1
View File
@@ -0,0 +1 @@
uid://ddaj0c8xfjitx