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]>
281 lines
12 KiB
GDScript
281 lines
12 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.
|
|
## 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"
|
|
|
|
# ── 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
|
|
|
|
|
|
## 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
|
|
|
|
|
|
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.
|
|
_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)
|
|
|
|
# ── 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_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", 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))
|
|
|
|
# ── 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
|
|
|
|
|
|
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()
|
|
|
|
|
|
# ── 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:
|
|
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)
|