feat(ui): one high-voltage theme, and pick your character from the escape menu

The theme was a comic one — cream paper, ink borders, papaya. It is now a
charged one: near-black violet, hot papaya, and a lightning yellow spent
nowhere except the instant a button is pressed. Chips are cut with two sharp
corners and two round ones on a diagonal, which is as close to a skew as a
StyleBoxFlat gets and is the difference between a button that reads calm and
one that reads fast. BoltRule draws the motif itself, struck a third of the way
along its rule rather than centred, so it reads as something that HIT the line.

The pause menu was 900 lines of hand-rolled UI that never referenced UITheme at
all, so it rendered in Godot's default grey. It now applies the theme — and
applies it to its own root Control, not only to the Window, because a Control
inherits from its nearest Control ANCESTOR and this screen hangs off a
CanvasLayer, which is not one. That was invisible at first: the parts built with
UITheme.title() carry their own overrides and looked right next to a list and a
button that did not.

And it now has a Character screen. The roster on the left, the character
themselves on the right, turning — a name in a dropdown is not a character
selection screen. The preview is a real SkinnedPlayerModel in its own world, so
it shows exactly what will spawn: the same cel look, the same per-class
outlines, the same cloth and hair on springs. Selecting applies immediately;
there is nothing destructive to confirm, and applying on selection means the
character behind the menu changes as you arrow the list, which IS the
comparison.

PlayerMovementController.set_skin() is the supported way in. Both halves of a
skin change are easy to do by halves — `synced_skin_id` is what REMOTE peers
rebuild from, and only their _process watches it, so setting the property alone
would change everyone else's view of you and not your own.

Checked rather than asserted. debug/character_picker_check.gd walks the whole
roster and proves each entry loads a skeleton, animations, a surface table and
body surfaces — and that the skeleton is MOVING, because the clip name is a
variable this class sets on itself and reads "Idle" just as happily when nothing
is ticking. debug/ui_capture.gd and debug/roster_capture.gd photograph the
screens and every character, which is how three things were found that no
assertion could see: the theme break above, a preview showing the back of the
character's head, and a turntable that carried on from the last character so the
third one you looked at was side-on.

Known and not fixed here: momo's idle pose is wrong — arms overhead and a
pinched waist. Her rig, not the picker; every other character is correct.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-27 14:46:14 -04:00
co-authored by Claude Opus 5
parent 53f175ed6d
commit 26f7c2c622
9 changed files with 850 additions and 79 deletions
+222 -64
View File
@@ -1,39 +1,95 @@
extends Object
class_name UITheme
## Shared comic/cel UI theme: Bangers display font, paper panels with thick
## ink borders and hard drop shadows, papaya accent. Built once, applied to
## the root Window so every Control in the game inherits it.
## 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.
## Three 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.
##
## 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.
const FONT_PATH := "res://assets/ui/fonts/Bangers-Regular.ttf"
const INK := Color(0.09, 0.08, 0.12)
const PAPER := Color(0.98, 0.96, 0.9)
const PAPER_DIM := Color(0.92, 0.9, 0.84)
const PAPAYA := Color(1.0, 0.55, 0.15)
const PAPAYA_HOT := Color(1.0, 0.68, 0.25)
const TEAL := Color(0.16, 0.72, 0.68)
# ── 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
## 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
## Comic panel stylebox: flat fill, thick ink border, hard offset shadow.
## 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.set_corner_radius_all(radius)
sb.content_margin_left = 16
sb.content_margin_right = 16
sb.content_margin_top = 8
sb.content_margin_bottom = 8
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.55)
sb.shadow_size = 0
sb.shadow_offset = Vector2(4, 4)
sb.shadow_size = 2
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
@@ -46,77 +102,179 @@ static func build() -> Theme:
t.default_font = font
t.default_font_size = 26
# ── Buttons: paper card, papaya on hover, squash on press ────────────
t.set_stylebox("normal", "Button", box(PAPER))
t.set_stylebox("hover", "Button", box(PAPAYA_HOT))
t.set_stylebox("pressed", "Button", box(PAPAYA, INK, 10, 3, false))
t.set_stylebox("focus", "Button", box(PAPER, TEAL))
t.set_stylebox("disabled", "Button", box(PAPER_DIM, Color(0.4, 0.4, 0.45)))
t.set_color("font_color", "Button", INK)
t.set_color("font_hover_color", "Button", INK)
t.set_color("font_pressed_color", "Button", PAPER)
t.set_color("font_focus_color", "Button", INK)
t.set_color("font_disabled_color", "Button", Color(0.45, 0.45, 0.5))
# ── 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.
_button_look(t, "Button")
for cls in ["OptionButton", "MenuButton", "CheckBox", "CheckButton",
"LinkButton"]:
# Godot theme types do not inherit styling from Button, so each one has
# to be told the same thing.
_button_look(t, cls)
# OptionButton / CheckBox get the same treatment explicitly (Godot theme
# types don't inherit styling from Button)
for cls in ["OptionButton", "MenuButton", "CheckBox", "CheckButton"]:
t.set_stylebox("normal", cls, box(PAPER))
t.set_stylebox("hover", cls, box(PAPAYA_HOT))
t.set_stylebox("pressed", cls, box(PAPAYA, INK, 10, 3, false))
t.set_stylebox("focus", cls, box(PAPER, TEAL))
t.set_stylebox("disabled", cls, box(PAPER_DIM, Color(0.4, 0.4, 0.45)))
t.set_color("font_color", cls, INK)
t.set_color("font_hover_color", cls, INK)
t.set_color("font_pressed_color", cls, PAPER)
t.set_color("font_focus_color", cls, INK)
t.set_color("font_disabled_color", cls, Color(0.45, 0.45, 0.5))
# ── Labels: ink outline everywhere for that inked-cel readability ────
# ── 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("font_color", "Label", PAPER)
# RichTextLabel (killfeed)
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(PAPER, INK, 8, 3, false))
t.set_stylebox("focus", "LineEdit", box(PAPER, TEAL, 8, 3, false))
t.set_color("font_color", "LineEdit", INK)
t.set_color("caret_color", "LineEdit", INK)
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_color("font_color", "LineEdit", PAPER)
t.set_color("caret_color", "LineEdit", VOLT)
t.set_color("font_placeholder_color", "LineEdit", PAPER_DIM)
# ── 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))
# ── Panels / lists ───────────────────────────────────────────────────
t.set_stylebox("panel", "PanelContainer", box(Color(0.13, 0.12, 0.18, 0.92), INK, 14, 4))
t.set_stylebox("panel", "ItemList", box(Color(0.13, 0.12, 0.18, 0.92), INK, 10, 3, false))
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)
t.set_stylebox("selected", "ItemList", box(PAPAYA, INK, 4, 2, false))
t.set_stylebox("selected_focus", "ItemList", box(PAPAYA_HOT, CYAN, 4, 2, false))
t.set_stylebox("hovered", "ItemList", box(INK_SOFT, PAPAYA, 4, 2, false))
# ── Popup menus (OptionButton dropdowns) ─────────────────────────────
t.set_stylebox("panel", "PopupMenu", box(PAPER, INK, 8, 3, false))
t.set_color("font_color", "PopupMenu", INK)
t.set_color("font_hover_color", "PopupMenu", PAPAYA)
# ── 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_color("font_selected_color", "TabContainer", INK)
t.set_color("font_unselected_color", "TabContainer", PAPER_DIM)
t.set_color("font_hovered_color", "TabContainer", INK)
# ── Popups (OptionButton dropdowns) ──────────────────────────────────
t.set_stylebox("panel", "PopupMenu", panel(PANEL_DEEP, PAPAYA, 3))
t.set_stylebox("hover", "PopupMenu", box(PAPAYA, INK, 4, 0, false))
t.set_color("font_color", "PopupMenu", PAPER)
t.set_color("font_hover_color", "PopupMenu", INK)
# ── 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)
_theme = t
return t
## Apply the theme to the whole root window (inherited by every Control in
## every scene from then on). Idempotent.
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))
t.set_stylebox("focus", cls, box(INK_SOFT, CYAN))
t.set_stylebox("disabled", cls, box(Color(0.10, 0.10, 0.14, 0.85), Color(0.28, 0.27, 0.33)))
t.set_color("font_color", cls, PAPER)
t.set_color("font_hover_color", cls, INK)
t.set_color("font_pressed_color", cls, INK)
t.set_color("font_focus_color", cls, PAPER)
t.set_color("font_disabled_color", cls, Color(0.42, 0.41, 0.48))
t.set_color("font_outline_color", cls, INK)
t.set_constant("outline_size", cls, 5)
## 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()
## Attach hover/click UI sounds to every BaseButton under `root` (recursive).
# ── 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 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
## 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:
var am = root.get_tree().root.get_node_or_null("AudioManager")
if am == null:
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)
btn.mouse_entered.connect(func(): am.play_ui("ui_hover"))
btn.pressed.connect(func(): am.play_ui("ui_click"))
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)