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:
2026-09-07 11:12:13 +02:00
parent 872922e9e2
commit 132646f6c3
54 changed files with 1319 additions and 50 deletions
+73 -20
View File
@@ -6,8 +6,17 @@ const MARGIN := 24.0
const BAR_W := 260.0
const BAR_H := 16.0
## Inventory slot box, and the gap between boxes.
const SLOT := 46.0
##
## Exactly twice the 32px source sprite. A non-integer scale on pixel art with
## nearest filtering gives uneven pixel widths, which on a 1px border reads as
## a wobble along the edge of every slot.
const SLOT := 64.0
const SLOT_GAP := 8.0
## Source-pixel width of the caps on the bar sprite. The bar is stretched only
## horizontally, so the ends are drawn at their own size and the middle takes
## whatever is left -- a plain stretched draw would smear a 1px border out to
## eight.
const BAR_CAP := 6.0
## How far above the bottom of the screen the inventory row sits.
const SLOT_BOTTOM := 26.0
@@ -31,6 +40,7 @@ func _ready() -> void:
# and the death-message centering down with it, since all three read
# _canvas.size. set_anchors_and_offsets_preset() sets both correctly.
_canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
UiTheme.apply_to(_canvas)
_canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE
_canvas.draw.connect(_draw_hud)
add_child(_canvas)
@@ -44,7 +54,10 @@ func _ready() -> void:
# here means "56px above the bottom anchor", which is what "-56" was meant
# to say in the first place.
_hint.offset_left = MARGIN
_hint.offset_top = -56.0
# Above the inventory row, not through it. Derived from the row's own
# geometry rather than written down, because the slots grew from 46px to 64
# and a hardcoded -56 put the control hints straight across them.
_hint.offset_top = -(SLOT_BOTTOM + SLOT + 34.0)
# A real Button rather than something drawn in _draw_hud: this is the one
# HUD element the player has to actually click, and it starts disabled so
@@ -126,13 +139,13 @@ func _draw_hud() -> void:
var origin := Vector2(MARGIN, MARGIN + 28.0)
_bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)),
Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18))
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 3.0))
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 4.0))
if client.my_escaping:
_bar(origin + Vector2(0.0, BAR_H + 8.0), client.my_escape,
_bar(origin + Vector2(0.0, (BAR_H + 4.0) * 2.0), client.my_escape,
Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2))
_canvas.draw_string(ThemeDB.fallback_font,
origin + Vector2(BAR_W + 12.0, BAR_H + 8.0 + BAR_H),
origin + Vector2(BAR_W + 12.0, (BAR_H + 4.0) * 2.0 + BAR_H - 3.0),
"ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0))
_draw_boss_bar()
@@ -195,15 +208,15 @@ func _draw_inventory() -> void:
var at := origin + Vector2(float(i) * (SLOT + SLOT_GAP), 0.0)
var item := Items.by_index(int(client.my_inventory[i]))
var def := Items.get_def(item)
var frame_col := Color(0.55, 0.6, 0.72, 0.75) if i == held \
else Color(0.3, 0.33, 0.42, 0.6)
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), Color(0.07, 0.08, 0.12, 0.72))
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), frame_col, false, 1.5)
# The held slot uses the pack's pressed-in slot art rather than a
# recoloured border, so "this is the one the key is on" reads the same
# way every other pressed thing in the interface does.
var slot_art := UiTheme.texture("slot_active" if i == held else "slot")
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false)
# The slot number, because the key that uses it is the only thing the
# player actually needs to know about a slot.
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(4.0, 13.0),
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 11,
Color(0.5, 0.55, 0.68))
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM)
if def == null:
continue
var icon := Art.item_icon(item)
@@ -248,13 +261,12 @@ func _draw_xp_bar(at: Vector2) -> void:
var capped := level >= Progression.MAX_LEVEL
var progress := Progression.level_progress(client.my_total_xp)
var tint := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.0)
_canvas.draw_rect(Rect2(at, Vector2(BAR_W, 5.0)), Color(0.1, 0.12, 0.18))
_canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 5.0)), tint)
_bar(at, progress, tint, Color.BLACK)
# The number as well as the bar: "how far to the next level" is a question
# a bar answers vaguely and a percentage answers exactly.
var text := "MAX" if capped else "%d%% to level %d" % [
int(floor(progress * 100.0)), level + 1]
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 6.0),
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 13.0),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, tint)
@@ -299,8 +311,13 @@ func _draw_boss_bar() -> void:
var w := 560.0
var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN)
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
_canvas.draw_rect(Rect2(pos, Vector2(w, BAR_H)), Color(0.12, 0.08, 0.1))
_canvas.draw_rect(Rect2(pos, Vector2(w * frac, BAR_H)), Color(0.95, 0.35, 0.45))
var rect := Rect2(pos, Vector2(w, BAR_H))
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inner := Rect2(rect.position + Vector2(3.0, 3.0),
Vector2((w - 6.0) * frac, BAR_H - 6.0))
if inner.size.x > 0.5:
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false,
Color(0.95, 0.35, 0.45))
var phase_index := int(b["phase"])
var phase_name := ""
if phase_index < client.boss_def.phases.size():
@@ -310,9 +327,45 @@ func _draw_boss_bar() -> void:
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8))
func _bar(pos: Vector2, frac: float, fill: Color, back: Color) -> void:
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W, BAR_H)), back)
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W * clampf(frac, 0.0, 1.0), BAR_H)), fill)
## A bar in the pack's art: the frame stretched horizontally, the fill tinted.
##
## [param back] is no longer a colour -- the frame sprite is the background --
## but the signature keeps it so callers read the same and a future flat-bar
## fallback has somewhere to go.
func _bar(pos: Vector2, frac: float, fill: Color, _back: Color) -> void:
var rect := Rect2(pos, Vector2(BAR_W, BAR_H))
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inset := 3.0
var inner := Rect2(rect.position + Vector2(inset, inset),
Vector2((rect.size.x - inset * 2.0) * clampf(frac, 0.0, 1.0),
rect.size.y - inset * 2.0))
if inner.size.x > 0.5:
# The fill sprite is a flat strip, so tinting it is the whole palette --
# one texture covers health, experience, the escape channel and the
# boss.
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false, fill)
## Draw a horizontally stretchable sprite as three slices: left cap, stretched
## middle, right cap. Godot's nine-patch lives on nodes rather than on the
## immediate-mode API, and the HUD is drawn rather than built out of controls.
func _hslice(tex: Texture2D, rect: Rect2, tint: Color) -> void:
if tex == null:
return
var src := Vector2(tex.get_size())
var cap := minf(BAR_CAP, src.x * 0.5)
var draw_cap := cap * (rect.size.y / src.y)
_canvas.draw_texture_rect_region(tex,
Rect2(rect.position, Vector2(draw_cap, rect.size.y)),
Rect2(0.0, 0.0, cap, src.y), tint)
_canvas.draw_texture_rect_region(tex,
Rect2(rect.position + Vector2(draw_cap, 0.0),
Vector2(maxf(rect.size.x - draw_cap * 2.0, 0.0), rect.size.y)),
Rect2(cap, 0.0, src.x - cap * 2.0, src.y), tint)
_canvas.draw_texture_rect_region(tex,
Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0),
Vector2(draw_cap, rect.size.y)),
Rect2(src.x - cap, 0.0, cap, src.y), tint)
func flash_hit() -> void: