Reaching a game was Singleplayer → pick a level. Reaching a multiplayer game
was Multiplayer → Host Game → wait → pick a level → wait, and the gamemode
dropdown had exactly one entry which set a string nothing read.
PLAY now starts the last map and mode played, and says which — "Akiba Crossing
• Deathmatch" under the button, so pressing it is a promise rather than a leap.
Everything else on the home screen is a detour from that, which is the right
shape for a menu: the common case is a button, not a path. Hosting is one press
and lands in the lobby already hosting; the lobby puts map, mode, players and
start on ONE screen; Enter connects, so typing an address does not then require
reaching for the mouse.
This is the lesson HoYoverse published about Zenless Zone Zero's first months
more loudly than anything else they have written. Their postmortem on the TV
mode names three complaints — it took too long, it sat between the player and
the combat, and there was too much of it early — and 1.2 removed it from the
story entirely rather than shortening it. Time spent BEFORE the thing the
player came for is not neutral, it is a cost.
The cards were a two-stop gradient generated from two colours in a meta file,
whose hover state was `use_hdr = true` — not a visible change on any of them.
They are photographs now, shot by debug/map_preview_capture.gd, which took
three attempts to get right and each attempt is a comment in the file:
- framing each map from OUTSIDE by merging every VisualInstance3D's AABB
produced five tiny dioramas floating on a table, and one solid black
rectangle. That is a minimap, and a minimap is not a photograph.
- standing at a spawn point fixed three maps and left two black: fps_blockout
is genuinely dark and most of its spawns face an unlit wall, and
procedural_arena builds its geometry at runtime so no fixed offset is
reliably inside it.
- so the tool now RENDERS several vantages and scores each result — mean
luminance times its standard deviation, because brightness alone picks the
empty sky and variance alone picks a high-contrast corner of a dark room.
Their product picks a photograph. A black rectangle passes any check that
only asks whether the camera ended up somewhere sensible.
Also fixed, and caught by looking at the screenshot: the selected mode chip was
unreadable. Its glyph is ink on volt, and the theme gave every button a 5 px INK
outline — an ink glyph inside an ink outline is not outlined, it is five pixels
fatter, and on a small chip that is a solid blob. Button labels now carry no
outline at all, which is the same reasoning debug/ui_contrast_check.gd already
encodes: an outline separates a glyph from a backdrop it cannot beat alone, and
a label on a solid chip does not have that problem. The chips keep their heavy
ink border, so nothing about the drawn look changes.
`current_gamemode` is normalised on the way in, because it used to hold a
display string and six callers still pass one. An unrecognised id compares
unequal to GameMode.GUN_GAME and would silently disable that mode's weapon
issuing — half a mode is worse than none.
The menu's background character is the player's OWN skin holding their weapon,
rather than the box-and-capsule mannequin that stood there before.
spawn smoke 0, game modes 30/30, movement 11/11, contrast 108/108, HUD layout
PASS, weapon holds 0, dances 0.
Co-Authored-By: Claude Opus 5 <[email protected]>
524 lines
25 KiB
GDScript
524 lines
25 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)
|
|
# NO OUTLINE on a button label.
|
|
#
|
|
# An outline exists to separate a glyph from a backdrop the glyph cannot beat
|
|
# on its own — over the 3D scene, or across a two-tone progress bar. A button
|
|
# label has neither problem: it sits on a solid chip, and `ink_for` has
|
|
# already given it a colour that beats that chip.
|
|
#
|
|
# Worse, the theme can only carry ONE outline colour per class, and half the
|
|
# states here use an ink glyph. Ink glyphs inside a 5 px ink outline are not
|
|
# outlined, they are five pixels fatter — the selected mode chip rendered as
|
|
# an unreadable dark blob on volt. The chips keep their heavy ink BORDER, so
|
|
# the drawn look is unaffected.
|
|
t.set_color("font_outline_color", cls, INK)
|
|
t.set_constant("outline_size", cls, 0)
|
|
# 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)
|