Files
Papay-Shooter/ui/ui_theme.gd
T
Nicholas ButzkeandClaude Opus 5 414026f001 feat(ui): every control state is checked for readability, not trusted
The palette has two light accents and one very dark one, and a control
changes its FILL on hover and press. Paper text that reads at 18:1 on the
resting near-black chip inverts to paper-on-yellow the moment the pointer
arrives, which is 1.1:1 — invisible. That is the standard way a stylised UI
becomes unreadable, and it was live on the OptionButton dropdown that every
settings row uses: PopupMenu draws its papaya hover fill but keeps
`font_color` unless `font_hover_color` is set, and it was not set.

So the label now follows the fill. `ink_for(fill)` picks the legible glyph
colour by contrast ratio, and every state's text, icon and outline is derived
from its own fill through it — including the states nobody remembers exist:
`hover_pressed` on a toggle (a CheckButton read as OFF while you touched it),
icon colours on a CheckBox that is all icon, and the list hover that used to
be the same papaya as list SELECTION, so the row you pointed at looked like
the row you had chosen.

debug/ui_contrast_check.gd interrogates the BUILT theme rather than the
palette — a table compared against itself agrees by construction and catches
nothing — and fails below the WCAG floor. It found the PopupMenu gap, a Tree
hover asking for a `font_hovered_color` Godot does not have, and the health
bar's readout at 2.4:1 over its own fill.

That last one is not fixable as a colour pair: a centred readout straddles a
hot papaya fill and a near-black trough, and no single colour beats both. What
carries it is the heavy ink outline this theme puts on every glyph, which is
its first stated rule and the same mechanism that keeps menu text legible
straight over the 3D scene. The check models that as a fallback route —
outline vs backdrop 3:1, glyph vs outline 4.5:1, at least 4 px — granted only
where the backdrop genuinely varies, and substituting two ratios for one
rather than waiving the requirement.

Disabled text moves from 3.2:1 to 5.2:1 on the way past. A greyed-out
"Start Match" is information; an illegible smudge is not.

Also styled, because the theme had simply never mentioned them and Godot's
defaults are grey-on-grey: scrollbars, Tree, SpinBox, ProgressBar, tooltips,
LineEdit read-only and selected text.

108 pairs checked, all passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 02:39:25 -04:00

512 lines
24 KiB
GDScript

extends Object
class_name UITheme
## The one UI theme: high-voltage anime. Built once, applied to the root Window,
## inherited by every Control in every scene.
##
## The look is a charged cel comic — near-black violet ink, hot papaya, and a
## lightning yellow that only ever appears at the moment something is pressed.
## Four rules hold it together:
##
## INK EVERYTHING every panel, chip and letter carries a heavy dark edge.
## It is what makes flat colour read as drawn rather than
## as flat, and it is why text stays legible over a 3D
## scene without a scrim behind it.
## LEAN chips are cut with two sharp corners and two round ones,
## on a diagonal. A uniformly rounded button reads calm; the
## same shape leaning reads fast, and it costs nothing.
## VOLT MEANS NOW yellow is reserved for the pressed state and the bolt.
## Spend it anywhere else and the moment of input stops
## standing out.
## EVERY STATE READS a fill and the text on it are chosen together, and the
## pair is checked. See `STATE_TABLE` and `contrast`.
##
## Everything here is static. Screens ask for `apply_global` once and then use
## the helpers — `heading`, `card`, `divider`, `chip_button` — instead of
## hand-rolling styleboxes, which is how the pause menu drifted 900 lines away
## from the rest of the game's look.
##
## ── Why the state table exists ────────────────────────────────────────────────
##
## The palette has two light accents (papaya, volt) and one very dark one (ink),
## and a control changes its FILL on hover and press. Text that reads perfectly
## at rest — paper on near-black — inverts to paper-on-yellow the instant the
## pointer arrives, which is a contrast ratio of 1.1:1. It is invisible.
##
## This is the single most common way a stylised UI becomes unreadable, and it is
## also the complaint HoYoverse's own audience has levelled at Zenless Zone
## Zero's menus. The fix is not vigilance, it is arithmetic: `ink_for` picks the
## legible text colour for any fill, every state declares its pair in
## `STATE_TABLE`, and `debug/ui_contrast_check.gd` fails the build if one of them
## drops below the WCAG floor. A state cannot ship unreadable without the check
## going red.
const FONT_PATH := "res://assets/ui/fonts/Bangers-Regular.ttf"
# ── The voltage palette ──────────────────────────────────────────────────────
const INK := Color(0.045, 0.040, 0.075)
const INK_SOFT := Color(0.105, 0.095, 0.165)
const PANEL := Color(0.075, 0.068, 0.125, 0.94)
const PANEL_DEEP := Color(0.045, 0.040, 0.085, 0.97)
const PAPER := Color(0.97, 0.96, 0.99)
const PAPER_DIM := Color(0.60, 0.58, 0.68)
const PAPAYA := Color(1.00, 0.47, 0.10)
const PAPAYA_HOT := Color(1.00, 0.64, 0.20)
## Reserved for the instant of input, and for the bolt. See VOLT MEANS NOW.
const VOLT := Color(1.00, 0.93, 0.22)
const CYAN := Color(0.22, 0.94, 1.00)
const MAGENTA := Color(1.00, 0.18, 0.52)
## Older screens name the accent `TEAL`; it is the electric cyan now.
const TEAL := CYAN
## Disabled: the fill goes flat and the label desaturates, but it does NOT go so
## dim that it stops being a word. A disabled control still has to say what it
## would do — "Start Match" greyed out is information, an illegible smudge is
## not. This pair sits above 5:1, where the old 0.42-grey sat at 3.2:1.
const DEAD_FILL := Color(0.10, 0.10, 0.14, 0.92)
const DEAD_EDGE := Color(0.30, 0.29, 0.36)
const DEAD_TEXT := Color(0.55, 0.54, 0.62)
## How far a chip leans. Applied as opposite corners round and the other two
## nearly square, which is as close to a skew as a StyleBoxFlat can get.
const LEAN := 18
static var _theme: Theme = null
# ── Contrast ─────────────────────────────────────────────────────────────────
## Relative luminance, per WCAG 2.1. Alpha is ignored: a chip's fill is drawn
## over the panel behind it, and assuming the fill is opaque is the pessimistic
## reading, which is the one worth checking.
static func luminance(c: Color) -> float:
var ch := [c.r, c.g, c.b]
for i in 3:
var v: float = ch[i]
ch[i] = v / 12.92 if v <= 0.04045 else pow((v + 0.055) / 1.055, 2.4)
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2]
## WCAG contrast ratio between two colours, 1.0 (identical) .. 21.0 (black on
## white). 4.5 is the floor for body text, 3.0 for text above ~24 px.
static func contrast(a: Color, b: Color) -> float:
var la := luminance(a)
var lb := luminance(b)
return (maxf(la, lb) + 0.05) / (minf(la, lb) + 0.05)
## The legible text colour for a given fill: ink on a light chip, paper on a dark
## one. This is what makes a hover safe — the fill is free to become papaya or
## volt because the label follows it.
static func ink_for(fill: Color) -> Color:
return INK if contrast(INK, fill) >= contrast(PAPER, fill) else PAPER
## Every (fill, text) pair the theme puts on screen, as
## `["<class>/<state>", fill, text, is_large_text]`.
##
## Written out rather than derived so the check has something to compare the
## built theme AGAINST — a table generated from the theme would agree with it by
## construction and catch nothing. Large text (headings, the wordmark, the HUD's
## big numerals) is allowed the 3:1 floor WCAG gives it.
static func state_table() -> Array:
var out: Array = []
for cls in BUTTON_CLASSES:
out.append_array([
[cls + "/normal", INK_SOFT, PAPER, false],
[cls + "/hover", PAPAYA, ink_for(PAPAYA), false],
[cls + "/pressed", VOLT, ink_for(VOLT), false],
[cls + "/focus", INK_SOFT, PAPER, false],
[cls + "/disabled", DEAD_FILL, DEAD_TEXT, false],
])
out.append_array([
["Label/on_panel", PANEL, PAPER, false],
["Label/dim_on_panel", PANEL, PAPER_DIM, false],
["Label/title", PANEL, PAPAYA, true],
["LineEdit/normal", INK_SOFT, PAPER, false],
["LineEdit/placeholder", INK_SOFT, PAPER_DIM, false],
["ItemList/normal", PANEL_DEEP, PAPER, false],
["ItemList/hovered", INK_SOFT, PAPER, false],
["ItemList/selected", PAPAYA, ink_for(PAPAYA), false],
["ItemList/selected_focus", PAPAYA_HOT, ink_for(PAPAYA_HOT), false],
["PopupMenu/normal", PANEL_DEEP, PAPER, false],
["PopupMenu/hover", PAPAYA, ink_for(PAPAYA), false],
["PopupMenu/disabled", PANEL_DEEP, DEAD_TEXT, false],
["TabContainer/selected", PAPAYA, ink_for(PAPAYA), false],
["TabContainer/unselected", INK_SOFT, PAPER_DIM, false],
["TabContainer/hovered", PAPAYA_HOT, ink_for(PAPAYA_HOT), false],
["Tree/normal", PANEL_DEEP, PAPER, false],
["Tree/selected", PAPAYA, ink_for(PAPAYA), false],
["Card/label", INK, PAPER, true],
["Card/hover_label", INK, VOLT, true],
])
return out
## Which theme types get the chip button treatment. Godot theme types do not
## inherit styling from Button, so each one has to be told the same thing.
const BUTTON_CLASSES := ["Button", "OptionButton", "MenuButton", "CheckBox",
"CheckButton", "LinkButton"]
# ── Styleboxes ───────────────────────────────────────────────────────────────
## A leaning comic chip: flat fill, heavy ink edge, hard offset shadow.
##
## Kept at its original name and argument order — main_menu and match_hud both
## call it — with the lean added on top.
static func box(bg: Color, border: Color = INK, radius: int = 10,
border_w: int = 3, shadow: bool = true) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = bg
sb.border_color = border
sb.set_border_width_all(border_w)
sb.corner_radius_top_left = radius + LEAN
sb.corner_radius_bottom_right = radius + LEAN
sb.corner_radius_top_right = 3
sb.corner_radius_bottom_left = 3
sb.content_margin_left = 22
sb.content_margin_right = 22
sb.content_margin_top = 9
sb.content_margin_bottom = 9
if shadow:
sb.shadow_color = Color(INK.r, INK.g, INK.b, 0.7)
sb.shadow_size = 3
sb.shadow_offset = Vector2(5, 5)
return sb
## A panel: same ink edge, but square-shouldered so it reads as a surface to put
## things on rather than as a very large button.
static func panel(bg: Color = PANEL, border: Color = INK,
border_w: int = 4) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = bg
sb.border_color = border
sb.set_border_width_all(border_w)
sb.set_corner_radius_all(6)
sb.content_margin_left = 20
sb.content_margin_right = 20
sb.content_margin_top = 16
sb.content_margin_bottom = 16
sb.shadow_color = Color(INK.r, INK.g, INK.b, 0.55)
sb.shadow_size = 4
sb.shadow_offset = Vector2(6, 6)
return sb
## A tight row chip — a list entry, a popup line, a tab — with the lean dropped.
##
## The lean is energy on a button the eye lands on. Repeated down twenty rows of
## a list it reads as a stack of broken rectangles, so rows get square corners
## and keep the fill and the edge.
static func row(bg: Color, border: Color = INK, border_w: int = 2) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = bg
sb.border_color = border
sb.set_border_width_all(border_w)
sb.set_corner_radius_all(4)
sb.content_margin_left = 12
sb.content_margin_right = 12
sb.content_margin_top = 5
sb.content_margin_bottom = 5
return sb
static func build() -> Theme:
if _theme:
return _theme
var t := Theme.new()
var font: Font = load(FONT_PATH) if ResourceLoader.exists(FONT_PATH) else null
if font:
t.default_font = font
t.default_font_size = 26
# ── Buttons ──────────────────────────────────────────────────────────
# Dark chip with a papaya edge at rest; the chip FILLS papaya on hover and
# flashes volt on press. The press state drops its shadow, so the chip
# visibly slams down into the page rather than just changing colour. Every
# state's text colour comes from `ink_for` its own fill, so the label
# inverts WITH the chip instead of vanishing into it.
for cls in BUTTON_CLASSES:
_button_look(t, cls)
# ── Labels: ink outline everywhere, for readability straight over 3D ──
t.set_color("font_color", "Label", PAPER)
t.set_color("font_outline_color", "Label", INK)
t.set_constant("outline_size", "Label", 8)
t.set_color("default_color", "RichTextLabel", PAPER)
t.set_color("font_outline_color", "RichTextLabel", INK)
t.set_constant("outline_size", "RichTextLabel", 6)
# ── Inputs ───────────────────────────────────────────────────────────
t.set_stylebox("normal", "LineEdit", box(INK_SOFT, PAPAYA, 4, 3, false))
t.set_stylebox("focus", "LineEdit", box(INK_SOFT, CYAN, 4, 3, false))
t.set_stylebox("read_only", "LineEdit", box(DEAD_FILL, DEAD_EDGE, 4, 3, false))
t.set_color("font_color", "LineEdit", PAPER)
t.set_color("font_uneditable_color", "LineEdit", DEAD_TEXT)
t.set_color("caret_color", "LineEdit", VOLT)
t.set_color("font_placeholder_color", "LineEdit", PAPER_DIM)
# Selected text: volt fill wants ink glyphs, same rule as a pressed chip.
t.set_color("font_selected_color", "LineEdit", INK)
t.set_color("selection_color", "LineEdit", VOLT)
t.set_stylebox("normal", "SpinBox", box(INK_SOFT, PAPAYA, 4, 3, false))
t.set_color("font_color", "SpinBox", PAPER)
# ── Sliders: the filled part is the charged part ─────────────────────
t.set_stylebox("slider", "HSlider", box(INK_SOFT, INK, 2, 2, false))
t.set_stylebox("grabber_area", "HSlider", box(PAPAYA, INK, 2, 2, false))
t.set_stylebox("grabber_area_highlight", "HSlider", box(VOLT, INK, 2, 2, false))
t.set_stylebox("slider", "VSlider", box(INK_SOFT, INK, 2, 2, false))
t.set_stylebox("grabber_area", "VSlider", box(PAPAYA, INK, 2, 2, false))
t.set_stylebox("grabber_area_highlight", "VSlider", box(VOLT, INK, 2, 2, false))
# ── Progress bars ────────────────────────────────────────────────────
t.set_stylebox("background", "ProgressBar", row(INK, INK_SOFT, 2))
t.set_stylebox("fill", "ProgressBar", row(PAPAYA, INK, 0))
t.set_color("font_color", "ProgressBar", PAPER)
t.set_color("font_outline_color", "ProgressBar", INK)
t.set_constant("outline_size", "ProgressBar", 6)
# ── Panels / lists ───────────────────────────────────────────────────
t.set_stylebox("panel", "PanelContainer", panel())
t.set_stylebox("panel", "Panel", panel())
t.set_stylebox("panel", "ItemList", panel(PANEL_DEEP, INK, 3))
t.set_color("font_color", "ItemList", PAPER)
t.set_color("font_selected_color", "ItemList", ink_for(PAPAYA))
t.set_color("font_hovered_color", "ItemList", PAPER)
t.set_color("font_outline_color", "ItemList", INK)
t.set_constant("outline_size", "ItemList", 5)
t.set_stylebox("selected", "ItemList", row(PAPAYA))
t.set_stylebox("selected_focus", "ItemList", row(PAPAYA_HOT, CYAN))
# Hover keeps PAPER text, so the fill must stay DARK. A papaya hover under a
# papaya selection also made the two states indistinguishable — the row you
# were pointing at looked exactly like the row you had chosen.
t.set_stylebox("hovered", "ItemList", row(INK_SOFT, PAPAYA))
t.set_stylebox("hovered_selected", "ItemList", row(PAPAYA_HOT, CYAN))
t.set_stylebox("cursor", "ItemList", row(Color(0, 0, 0, 0), CYAN))
t.set_stylebox("cursor_unfocused", "ItemList", row(Color(0, 0, 0, 0), PAPER_DIM))
# ── Trees (settings, loadout lists) ──────────────────────────────────
t.set_stylebox("panel", "Tree", panel(PANEL_DEEP, INK, 3))
t.set_color("font_color", "Tree", PAPER)
t.set_color("font_selected_color", "Tree", ink_for(PAPAYA))
t.set_color("font_outline_color", "Tree", INK)
t.set_constant("outline_size", "Tree", 5)
t.set_stylebox("selected", "Tree", row(PAPAYA))
t.set_stylebox("selected_focus", "Tree", row(PAPAYA_HOT, CYAN))
t.set_stylebox("hovered", "Tree", row(INK_SOFT, PAPAYA))
t.set_stylebox("hovered_selected", "Tree", row(PAPAYA_HOT, CYAN))
# ── Scrollbars: visible at rest, charged under the thumb ─────────────
for cls in ["HScrollBar", "VScrollBar"]:
t.set_stylebox("scroll", cls, row(INK, INK_SOFT, 1))
t.set_stylebox("grabber", cls, row(PAPER_DIM, INK, 1))
t.set_stylebox("grabber_highlight", cls, row(PAPAYA, INK, 1))
t.set_stylebox("grabber_pressed", cls, row(VOLT, INK, 1))
# ── Tabs ─────────────────────────────────────────────────────────────
t.set_stylebox("panel", "TabContainer", panel())
t.set_stylebox("tab_selected", "TabContainer", box(PAPAYA, INK, 6, 3, false))
t.set_stylebox("tab_unselected", "TabContainer", box(INK_SOFT, INK, 6, 3, false))
t.set_stylebox("tab_hovered", "TabContainer", box(PAPAYA_HOT, INK, 6, 3, false))
t.set_stylebox("tab_disabled", "TabContainer", box(DEAD_FILL, DEAD_EDGE, 6, 3, false))
t.set_color("font_selected_color", "TabContainer", ink_for(PAPAYA))
t.set_color("font_unselected_color", "TabContainer", PAPER_DIM)
t.set_color("font_hovered_color", "TabContainer", ink_for(PAPAYA_HOT))
t.set_color("font_disabled_color", "TabContainer", DEAD_TEXT)
t.set_color("font_outline_color", "TabContainer", INK)
t.set_constant("outline_size", "TabContainer", 5)
# ── Popups (OptionButton dropdowns) ──────────────────────────────────
#
# The dropdown is the one surface where a missing hover colour is fatal: a
# PopupMenu draws its hover fill but keeps `font_color` unless
# `font_hover_color` is set, so a papaya row under paper text was the least
# readable thing in the game and it appeared on every settings menu.
t.set_stylebox("panel", "PopupMenu", panel(PANEL_DEEP, PAPAYA, 3))
t.set_stylebox("hover", "PopupMenu", row(PAPAYA, INK, 0))
t.set_color("font_color", "PopupMenu", PAPER)
t.set_color("font_hover_color", "PopupMenu", ink_for(PAPAYA))
t.set_color("font_disabled_color", "PopupMenu", DEAD_TEXT)
t.set_color("font_accelerator_color", "PopupMenu", PAPER_DIM)
t.set_color("font_separator_color", "PopupMenu", PAPAYA)
t.set_color("font_outline_color", "PopupMenu", INK)
t.set_constant("outline_size", "PopupMenu", 5)
# ── Dialogs ──────────────────────────────────────────────────────────
t.set_stylebox("panel", "AcceptDialog", panel(PANEL_DEEP, PAPAYA, 4))
t.set_stylebox("embedded_border", "Window", panel(PANEL_DEEP, PAPAYA, 4))
t.set_color("title_color", "Window", VOLT)
# ── Tooltips ─────────────────────────────────────────────────────────
t.set_stylebox("panel", "TooltipPanel", panel(PANEL_DEEP, VOLT, 2))
t.set_color("font_color", "TooltipLabel", PAPER)
t.set_color("font_outline_color", "TooltipLabel", INK)
t.set_constant("outline_size", "TooltipLabel", 5)
_theme = t
return t
static func _button_look(t: Theme, cls: String) -> void:
t.set_stylebox("normal", cls, box(INK_SOFT, PAPAYA))
t.set_stylebox("hover", cls, box(PAPAYA, INK))
t.set_stylebox("pressed", cls, box(VOLT, INK, 10, 3, false))
# Hover-while-pressed is its own stylebox on a toggle button. Without it a
# CheckButton the pointer is over reverts to the hover fill, so a toggle
# reads as OFF for as long as you are touching it.
t.set_stylebox("hover_pressed", cls, box(VOLT, CYAN, 10, 3, false))
t.set_stylebox("focus", cls, box(INK_SOFT, CYAN))
t.set_stylebox("disabled", cls, box(DEAD_FILL, DEAD_EDGE))
t.set_color("font_color", cls, PAPER)
t.set_color("font_hover_color", cls, ink_for(PAPAYA))
t.set_color("font_pressed_color", cls, ink_for(VOLT))
t.set_color("font_hover_pressed_color", cls, ink_for(VOLT))
t.set_color("font_focus_color", cls, PAPER)
t.set_color("font_disabled_color", cls, DEAD_TEXT)
t.set_color("font_outline_color", cls, INK)
t.set_constant("outline_size", cls, 5)
# The icon has to invert with the label. A paper glyph on a volt chip is the
# same 1.1:1 the text would have been, and a CheckBox is ALL icon.
t.set_color("icon_normal_color", cls, PAPER)
t.set_color("icon_hover_color", cls, ink_for(PAPAYA))
t.set_color("icon_pressed_color", cls, ink_for(VOLT))
t.set_color("icon_hover_pressed_color", cls, ink_for(VOLT))
t.set_color("icon_focus_color", cls, PAPER)
t.set_color("icon_disabled_color", cls, DEAD_TEXT)
## Apply the theme to the whole root window. Idempotent, and cheap enough that
## every screen should just call it in `_ready` rather than assuming some other
## screen already did — which is exactly what left the pause menu unstyled
## whenever it was opened before the HUD had loaded.
static func apply_global(tree: SceneTree) -> void:
if tree and tree.root and tree.root.theme != build():
tree.root.theme = build()
# ── Widgets ──────────────────────────────────────────────────────────────────
## The wordmark treatment: papaya on a heavy ink outline, tilted off true.
##
## The tilt is the cheapest energy in the whole theme. Two and a half degrees is
## enough for the eye to register that the type is not sitting square and not
## enough to read as broken.
static func title(text: String, size: int = 64, tilt: float = -2.5) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", PAPAYA)
l.add_theme_color_override("font_outline_color", INK)
l.add_theme_constant_override("outline_size", int(maxf(8.0, size * 0.16)))
l.rotation_degrees = tilt
l.pivot_offset = Vector2(0, size * 0.5)
return l
## A section heading — smaller, paper-coloured, no tilt.
static func heading(text: String, size: int = 32) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", PAPER)
l.add_theme_color_override("font_outline_color", INK)
l.add_theme_constant_override("outline_size", 8)
return l
## A quieter line of body copy — a hint, a port number, a mode's description.
static func caption(text: String, size: int = 20) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", PAPER_DIM)
l.add_theme_color_override("font_outline_color", INK)
l.add_theme_constant_override("outline_size", 5)
return l
## A bolt-struck rule, for separating sections.
static func divider(strike_at: float = 0.34) -> BoltRule:
var b := BoltRule.new()
b.line_color = PAPAYA
b.bolt_color = VOLT
b.ink = INK
b.strike_at = strike_at
return b
## A panel to put things on, already themed.
static func card() -> PanelContainer:
var p := PanelContainer.new()
p.add_theme_stylebox_override("panel", panel())
return p
## The one loud button on a screen — the thing the player came here to press.
##
## Papaya-filled at rest instead of ink-filled, so it is the first thing the eye
## lands on, and correspondingly ink-lettered. Hover goes hotter and press still
## flashes volt, so the state ladder is unchanged; only the resting colour moves.
static func primary_button(text: String, size: int = 44) -> Button:
var b := Button.new()
b.text = text
b.add_theme_font_size_override("font_size", size)
b.add_theme_stylebox_override("normal", box(PAPAYA, INK, 12, 4))
b.add_theme_stylebox_override("hover", box(PAPAYA_HOT, VOLT, 12, 4))
b.add_theme_stylebox_override("pressed", box(VOLT, INK, 12, 4, false))
b.add_theme_stylebox_override("focus", box(PAPAYA, CYAN, 12, 4))
b.add_theme_color_override("font_color", ink_for(PAPAYA))
b.add_theme_color_override("font_hover_color", ink_for(PAPAYA_HOT))
b.add_theme_color_override("font_pressed_color", ink_for(VOLT))
b.add_theme_color_override("font_focus_color", ink_for(PAPAYA))
b.add_theme_color_override("font_outline_color", Color(INK.r, INK.g, INK.b, 0.55))
b.add_theme_constant_override("outline_size", 4)
return b
## Sounds and the hover kick for every button under `root` (recursive).
##
## The kick is a 4% scale-up on hover and a snap back on exit. It is small on
## purpose: the theme already changes the chip's fill colour, and a big scale on
## top of that reads as the button wobbling rather than as it responding.
static func wire_sounds(root: Node) -> void:
if root == null or not root.is_inside_tree():
return
var am = root.get_tree().root.get_node_or_null("AudioManager")
for btn in root.find_children("*", "BaseButton", true, false):
if btn.has_meta("ui_sfx_wired"):
continue
btn.set_meta("ui_sfx_wired", true)
if am:
btn.mouse_entered.connect(func(): am.play_ui("ui_hover"))
btn.pressed.connect(func(): am.play_ui("ui_click"))
_wire_kick(btn)
static func _wire_kick(btn: Control) -> void:
btn.mouse_entered.connect(func(): _kick(btn, 1.04))
btn.mouse_exited.connect(func(): _kick(btn, 1.0))
btn.focus_entered.connect(func(): _kick(btn, 1.04))
btn.focus_exited.connect(func(): _kick(btn, 1.0))
static func _kick(btn: Control, to: float) -> void:
if not is_instance_valid(btn) or not btn.is_inside_tree():
return
# Scale about the chip's own centre, or it grows off to one side. The pivot
# is set every time because a container can resize the button after it was
# wired, and a stale pivot is what makes a hover look like a lurch.
btn.pivot_offset = btn.size * 0.5
var tw := btn.create_tween()
# The menu runs while the tree is paused, so the tween has to as well.
tw.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS)
tw.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tw.tween_property(btn, "scale", Vector2(to, to), 0.12)