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)