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:
co-authored by
Claude Opus 5
parent
53f175ed6d
commit
26f7c2c622
+222
-64
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user