Style the rest of the interface: panels, sliders, scrollbars, cards
ci / verify (push) Successful in 48s
ci / verify (push) Successful in 48s
Buttons were the easy half. This is everything else. Dialogs sit on panels now rather than being text over a dimmed world -- the pause menu, character select, upgrades, settings and credits all root through UiTheme.dialog_panel(). The panel stylebox is the pack's frame MODULATED DARK. The set is cream throughout, which is right for buttons and wrong for a dialog over a dark dungeon; tinting keeps the pixel border and the corner shape and lets every label the game already draws in light colours stay readable, instead of recolouring every label in five screens to suit the art. Sliders and scrollbars are the pack's too, section headings sit on its banner ribbon, and an upgrade card's rarity is now its frame rather than a word on it -- three choices are compared at a glance and a colour reads faster than a label. Two bugs of the same shape, and neither was findable without looking at the screen. A Slider and a ScrollBar take their THICKNESS from the stylebox's minimum size, which for a StyleBoxTexture is its content margins. Mine were zero, so both resolved the correct stylebox, reported the correct texture, and drew a groove zero pixels tall. A probe confirmed the theme was resolving perfectly while the track was invisible. Tests now assert every slider and scrollbar stylebox has a non-zero minimum, and it is gotcha 7 in CLAUDE.md. The first attempt at the slider groove also used the pack's HOLLOW bar sprite, whose middle is transparent -- tinting it dark left an outline and nothing else. It uses the solid one. tools/screenshot.tscn gained the upgrade screen, which it has to stage: the game scene owns that screen's visibility and re-asserts it every frame, so setting `visible` lasted exactly one frame, and standing the player at the NPC client-side lasted until reconciliation pulled them back. It now moves the player on both sides and holds it until the shot. That tool has caught four bugs the automated gates all passed. check.sh clean, 468 tests, SMOKE PASS, all four diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,10 +36,15 @@ func _ready() -> void:
|
||||
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
root.add_child(centre)
|
||||
|
||||
# A panel, so a dialog reads as a thing laid over the world rather
|
||||
# than as text floating on a dimmed one.
|
||||
var frame := UiTheme.dialog_panel()
|
||||
centre.add_child(frame)
|
||||
|
||||
var panel := VBoxContainer.new()
|
||||
panel.custom_minimum_size = Vector2(460.0, 0.0)
|
||||
panel.add_theme_constant_override("separation", 8)
|
||||
centre.add_child(panel)
|
||||
frame.add_child(panel)
|
||||
|
||||
_title = Label.new()
|
||||
_title.text = "CHARACTERS"
|
||||
|
||||
@@ -28,10 +28,15 @@ func _ready() -> void:
|
||||
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
root.add_child(centre)
|
||||
|
||||
# A panel, so a dialog reads as a thing laid over the world rather
|
||||
# than as text floating on a dimmed one.
|
||||
var frame := UiTheme.dialog_panel()
|
||||
centre.add_child(frame)
|
||||
|
||||
var panel := VBoxContainer.new()
|
||||
panel.custom_minimum_size = Vector2(620.0, 0.0)
|
||||
panel.add_theme_constant_override("separation", 8)
|
||||
centre.add_child(panel)
|
||||
frame.add_child(panel)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "CREDITS"
|
||||
|
||||
+6
-1
@@ -37,10 +37,15 @@ func _ready() -> void:
|
||||
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
root.add_child(center)
|
||||
|
||||
# A panel, so a dialog reads as a thing laid over the world rather
|
||||
# than as text floating on a dimmed one.
|
||||
var frame := UiTheme.dialog_panel()
|
||||
center.add_child(frame)
|
||||
|
||||
_panel = VBoxContainer.new()
|
||||
_panel.custom_minimum_size = Vector2(320.0, 0.0)
|
||||
_panel.add_theme_constant_override("separation", 10)
|
||||
center.add_child(_panel)
|
||||
frame.add_child(_panel)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "PAUSED"
|
||||
|
||||
@@ -34,10 +34,15 @@ func _ready() -> void:
|
||||
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
root.add_child(centre)
|
||||
|
||||
# A panel, so a dialog reads as a thing laid over the world rather
|
||||
# than as text floating on a dimmed one.
|
||||
var frame := UiTheme.dialog_panel()
|
||||
centre.add_child(frame)
|
||||
|
||||
var panel := VBoxContainer.new()
|
||||
panel.custom_minimum_size = Vector2(520.0, 0.0)
|
||||
panel.add_theme_constant_override("separation", 8)
|
||||
centre.add_child(panel)
|
||||
frame.add_child(panel)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "SETTINGS"
|
||||
@@ -93,12 +98,35 @@ func _ready() -> void:
|
||||
buttons.add_child(close)
|
||||
|
||||
|
||||
func _heading(text: String) -> Label:
|
||||
## A section heading on the pack's banner ribbon, so the two halves of this
|
||||
## screen are visibly two sections rather than one long list.
|
||||
## A section heading on the pack's banner ribbon.
|
||||
##
|
||||
## Deliberately narrow and centred rather than stretched across the dialog: at
|
||||
## full width the ribbon stops reading as a label and becomes a bright divider
|
||||
## competing with everything under it.
|
||||
func _heading(text: String) -> Control:
|
||||
var row := CenterContainer.new()
|
||||
var holder := MarginContainer.new()
|
||||
holder.custom_minimum_size = Vector2(210.0, 34.0)
|
||||
row.add_child(holder)
|
||||
var banner := NinePatchRect.new()
|
||||
banner.texture = UiTheme.texture("banner")
|
||||
# The ribbon's tails hang off both ends; stretching through them would
|
||||
# smear the shape, so they are the patch margins and only the body grows.
|
||||
banner.patch_margin_left = 14
|
||||
banner.patch_margin_right = 14
|
||||
banner.patch_margin_top = 8
|
||||
banner.patch_margin_bottom = 8
|
||||
holder.add_child(banner)
|
||||
var l := Label.new()
|
||||
l.text = text
|
||||
l.add_theme_font_size_override("font_size", 14)
|
||||
l.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78))
|
||||
return l
|
||||
l.add_theme_font_size_override("font_size", 13)
|
||||
l.add_theme_color_override("font_color", UiTheme.INK)
|
||||
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
holder.add_child(l)
|
||||
return row
|
||||
|
||||
|
||||
func _slider_row(parent: Control, label: String) -> Array:
|
||||
|
||||
@@ -43,10 +43,15 @@ func _ready() -> void:
|
||||
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
root.add_child(centre)
|
||||
|
||||
# A panel, so a dialog reads as a thing laid over the world rather
|
||||
# than as text floating on a dimmed one.
|
||||
var frame := UiTheme.dialog_panel()
|
||||
centre.add_child(frame)
|
||||
|
||||
var panel := VBoxContainer.new()
|
||||
panel.custom_minimum_size = Vector2(760.0, 0.0)
|
||||
panel.add_theme_constant_override("separation", 10)
|
||||
centre.add_child(panel)
|
||||
frame.add_child(panel)
|
||||
|
||||
_title = Label.new()
|
||||
_title.add_theme_font_size_override("font_size", 26)
|
||||
@@ -116,12 +121,16 @@ func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) -
|
||||
|
||||
func _make_card(index: int, id: StringName) -> Control:
|
||||
var def := Upgrades.get_def(id)
|
||||
var card := VBoxContainer.new()
|
||||
card.custom_minimum_size = Vector2(240.0, 0.0)
|
||||
card.add_theme_constant_override("separation", 6)
|
||||
if def == null:
|
||||
return card
|
||||
return Control.new()
|
||||
var tint: Color = RARITY_COLOURS.get(def.rarity, Color.WHITE)
|
||||
# The rarity is the card's frame, not just a word on it. Three cards side by
|
||||
# side are compared at a glance, and a colour reads faster than a label.
|
||||
var frame := UiTheme.card_panel(tint.lerp(Color(0.10, 0.10, 0.14), 0.72))
|
||||
frame.custom_minimum_size = Vector2(244.0, 0.0)
|
||||
var card := VBoxContainer.new()
|
||||
card.add_theme_constant_override("separation", 6)
|
||||
frame.add_child(card)
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = def.display_name
|
||||
@@ -138,7 +147,7 @@ func _make_card(index: int, id: StringName) -> Control:
|
||||
var body := Label.new()
|
||||
body.text = def.description
|
||||
body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
body.custom_minimum_size = Vector2(230.0, 96.0)
|
||||
body.custom_minimum_size = Vector2(214.0, 104.0)
|
||||
body.add_theme_font_size_override("font_size", 13)
|
||||
card.add_child(body)
|
||||
|
||||
@@ -156,7 +165,7 @@ func _make_card(index: int, id: StringName) -> Control:
|
||||
take.custom_minimum_size = Vector2(0.0, 34.0)
|
||||
take.pressed.connect(func() -> void: choose_requested.emit(index))
|
||||
card.add_child(take)
|
||||
return card
|
||||
return frame
|
||||
|
||||
|
||||
## Counted rather than listed one per line: "Split Shot x3" is the number that
|
||||
|
||||
+92
-2
@@ -24,14 +24,30 @@ const TEXTURES := {
|
||||
"button_disabled": DIR + "Button01a_4.png",
|
||||
"panel": DIR + "Frame01a.png",
|
||||
"panel_accent": DIR + "Frame02a.png",
|
||||
"panel_warn": DIR + "Frame03a.png",
|
||||
"card": DIR + "FrameMarker01a.png",
|
||||
"slot": DIR + "FrameSlot01a.png",
|
||||
"slot_active": DIR + "FrameSlot01b.png",
|
||||
"bar": DIR + "Bar01a.png",
|
||||
"bar_fill": DIR + "BarFill01f.png",
|
||||
"track": DIR + "Bar05a.png",
|
||||
"trough": DIR + "Bar07a.png",
|
||||
"grabber": DIR + "Handle03a.png",
|
||||
"grabber_small": DIR + "Handle02a.png",
|
||||
"field": DIR + "InputField01a.png",
|
||||
"banner": DIR + "Banner01a.png",
|
||||
}
|
||||
|
||||
## Panels are the pack's shape in this game's colours.
|
||||
##
|
||||
## The set is cream throughout, which is right for its buttons -- they are meant
|
||||
## to be the bright thing you press -- and wrong for a full-screen dialog laid
|
||||
## over a dark dungeon. Modulating the panel art keeps the pixel border and the
|
||||
## corner shape and lets every label the game already draws in light colours
|
||||
## stay readable, instead of a rewrite of every colour override in five screens.
|
||||
const PANEL_TINT := Color(0.17, 0.18, 0.24)
|
||||
const TRACK_TINT := Color(0.30, 0.32, 0.40)
|
||||
|
||||
## 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.
|
||||
@@ -67,8 +83,50 @@ static func get_theme() -> Theme:
|
||||
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))
|
||||
var dialog := _box("panel", 10, 22, 18)
|
||||
dialog.modulate_color = PANEL_TINT
|
||||
t.set_stylebox("panel", "Panel", dialog)
|
||||
t.set_stylebox("panel", "PanelContainer", dialog)
|
||||
|
||||
# Sliders: a groove, a filled portion, and a grabber big enough to aim at.
|
||||
#
|
||||
# The groove uses the SOLID bar rather than the pack's hollow one. Tinting a
|
||||
# sprite whose middle is transparent leaves an outline and nothing else, so
|
||||
# the first version of this had an invisible track and a grabber apparently
|
||||
# floating in space.
|
||||
# The content margins are load-bearing here, not padding: a Slider draws its
|
||||
# groove in a band whose thickness IS the stylebox's minimum size, and a
|
||||
# stylebox with no content margins has a minimum size of zero. With them at
|
||||
# 0 the track resolved correctly, reported the right texture, and drew
|
||||
# nothing at all.
|
||||
var groove := _box("trough", 4, 0, 5)
|
||||
groove.modulate_color = TRACK_TINT
|
||||
t.set_stylebox("slider", "HSlider", groove)
|
||||
var filled := _box("bar_fill", 1, 0, 4)
|
||||
filled.modulate_color = Color(0.55, 0.78, 1.0)
|
||||
t.set_stylebox("grabber_area", "HSlider", filled)
|
||||
t.set_stylebox("grabber_area_highlight", "HSlider", filled)
|
||||
# Twice size, not three times: the pack's handle is 8x10 and at 3x it was
|
||||
# taller than the row it sits in.
|
||||
var grab := scaled("grabber", 2)
|
||||
t.set_icon("grabber", "HSlider", grab)
|
||||
t.set_icon("grabber_highlight", "HSlider", grab)
|
||||
t.set_icon("grabber_disabled", "HSlider", grab)
|
||||
t.set_constant("center_grabber", "HSlider", 1)
|
||||
|
||||
# Scrollbars. Two screens scroll, and Godot's default bar is the one
|
||||
# remaining piece of grey chrome once everything else is the pack's.
|
||||
# Content margins again, and for the same reason: a ScrollBar takes its
|
||||
# THICKNESS from the minimum size of its styleboxes. At zero the bar is
|
||||
# zero pixels wide, which is indistinguishable from having no scrollbar --
|
||||
# and that is precisely how it looked.
|
||||
for bar_class in ["VScrollBar", "HScrollBar"]:
|
||||
var trough := _box("trough", 4, 5, 5)
|
||||
trough.modulate_color = TRACK_TINT
|
||||
t.set_stylebox("scroll", bar_class, trough)
|
||||
t.set_stylebox("scroll_focus", bar_class, trough)
|
||||
for state in ["grabber", "grabber_highlight", "grabber_pressed"]:
|
||||
t.set_stylebox(state, bar_class, _box("grabber_small", 3, 5, 5))
|
||||
|
||||
t.set_stylebox("normal", "LineEdit", _box("field", 8, 10, 6))
|
||||
t.set_stylebox("focus", "LineEdit", _box("field", 8, 10, 6))
|
||||
@@ -102,6 +160,38 @@ static func themed_root() -> Control:
|
||||
return root
|
||||
|
||||
|
||||
## A copy of a texture at a whole multiple of its size, nearest-filtered.
|
||||
##
|
||||
## For theme items Godot draws at the texture's own size -- a slider grabber,
|
||||
## for one -- where the pack's 8x10 handle would otherwise be a speck. Nearest
|
||||
## rather than smooth: this is pixel art, and a bilinear upscale of an 8px
|
||||
## sprite is mush.
|
||||
static func scaled(key: String, factor: int) -> ImageTexture:
|
||||
var img := texture(key).get_image()
|
||||
img.resize(img.get_width() * factor, img.get_height() * factor,
|
||||
Image.INTERPOLATE_NEAREST)
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
## A panel to put a dialog's contents in, so a screen is a thing on the world
|
||||
## rather than text floating over it.
|
||||
static func dialog_panel() -> PanelContainer:
|
||||
var frame := PanelContainer.new()
|
||||
apply_to(frame)
|
||||
return frame
|
||||
|
||||
|
||||
## A card: the pack's marker frame, tinted. Used for the upgrade choices, where
|
||||
## the tint carries the rarity.
|
||||
static func card_panel(tint: Color) -> PanelContainer:
|
||||
var card := PanelContainer.new()
|
||||
apply_to(card)
|
||||
var style := _box("card", 10, 12, 10)
|
||||
style.modulate_color = tint
|
||||
card.add_theme_stylebox_override("panel", style)
|
||||
return card
|
||||
|
||||
|
||||
## 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:
|
||||
|
||||
Reference in New Issue
Block a user