From 26f7c2c62290957b4d92bfa22ccd972307aecd7c Mon Sep 17 00:00:00 2001 From: Nicholas Butzke <137417822+DottsGit@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:46:14 -0400 Subject: [PATCH] feat(ui): one high-voltage theme, and pick your character from the escape menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- debug/character_picker_check.gd | 123 +++++++++++ debug/roster_capture.gd | 84 ++++++++ debug/ui_capture.gd | 68 ++++++ debug/weapon_lab.tscn | 2 +- movement/player_movement_controller.gd | 23 ++ ui/bolt_rule.gd | 71 ++++++ ui/bolt_rule.gd.uid | 1 + ui/pause_menu.gd | 271 +++++++++++++++++++++-- ui/ui_theme.gd | 286 +++++++++++++++++++------ 9 files changed, 850 insertions(+), 79 deletions(-) create mode 100644 debug/character_picker_check.gd create mode 100644 debug/roster_capture.gd create mode 100644 debug/ui_capture.gd create mode 100644 ui/bolt_rule.gd create mode 100644 ui/bolt_rule.gd.uid diff --git a/debug/character_picker_check.gd b/debug/character_picker_check.gd new file mode 100644 index 0000000..8f42c73 --- /dev/null +++ b/debug/character_picker_check.gd @@ -0,0 +1,123 @@ +extends SceneTree + +## Does the escape menu's character picker actually work? +## +## It is built entirely in code, in an autoload, over a paused tree — three +## things that each hide their own class of mistake and none of which a compile +## check catches. So: open it, walk every entry, and assert that each one +## selects, describes itself, and builds a real model with a real skeleton. +## +## godot --headless --path . -s res://debug/character_picker_check.gd + +var _fails: int = 0 + +func _init() -> void: + root.call_deferred("add_child", Node.new()) # let autoloads finish _ready + await process_frame + await process_frame + + var menu = root.get_node_or_null("PauseMenu") + _check(menu != null, "PauseMenu autoload exists") + if menu == null: + _done() + return + + _check(menu.character_btn != null, "Character button exists on the pause menu") + _check(menu.character_list != null, "Character list exists") + _check(menu.character_editor != null, "Character screen exists") + + menu._show_character() + await process_frame + _check(menu.character_editor.visible, "Character screen shows") + _check(not menu.main_vbox.visible, "Main pause list hides behind it") + + # Autoload singletons are not resolvable as identifiers from a `-s` SceneTree + # script — it is compiled before they register — so reach it by path. + var skin_mgr = root.get_node("SkinManager") + + var count: int = menu.character_list.item_count + _check(count > 0, "Roster is not empty (%d entries)" % count) + + var seen_glb := 0 + for i in count: + var id: String = menu.character_list.get_item_metadata(i) + menu._on_character_selected(i) + await process_frame + await process_frame + _check(menu.character_desc.text != "", "'%s' has a description line" % id) + + var skin = skin_mgr.get_skin(id) + var expects_model: bool = skin.model_path != "" \ + and ResourceLoader.exists(skin.model_path) + if not expects_model: + # A colour-tint skin has no GLB. The preview must be EMPTY, not the + # previously selected character left standing there. + _check(menu._preview_model == null, + "'%s' is a colour skin and clears the preview" % id) + continue + seen_glb += 1 + var model = menu._preview_model + _check(model != null, "'%s' builds a preview model" % id) + if model == null: + continue + _check(model.loaded, "'%s' preview finished loading" % id) + _check(model.skeleton != null, "'%s' preview has a skeleton" % id) + _check(model.animation_player != null, "'%s' preview has animations" % id) + _check(model.surface_table() != null and not model.surface_table().is_empty(), + "'%s' preview knows its surface classes" % id) + var body: Array = model.surfaces_of(SkinSurfaces.BODY) + _check(not body.is_empty(), "'%s' preview reports body surfaces" % id) + # A preview that is not ANIMATING is a preview of the bind pose, which + # is the one pose the character will never be in during play. The clip + # NAME is not evidence of that — it is a variable this class sets on + # itself, and it reads "Idle" just as happily when the animation tree + # is not ticking at all. So watch the skeleton move. + _check(model.current_clip_debug() == "Idle", + "'%s' preview selected Idle (got '%s')" + % [id, model.current_clip_debug()]) + _check(await _pose_moves(model), + "'%s' preview skeleton is actually animating" % id) + + _check(seen_glb >= 6, "every shipping GLB skin previewed (%d)" % seen_glb) + + # Back out, and make sure the turntable stops costing frames. + menu._show_main_menu() + await process_frame + _check(not menu.character_editor.visible, "Back returns to the pause list") + _check(menu.main_vbox.visible, "Pause list is showing again") + + _done() + + +## Does the skeleton's pose change over a handful of frames? +## +## Sampled from INSIDE the modifier pass would be better, but the question here +## is only "is anything driving this at all", and for that the animated pose is +## the right thing to read: if the AnimationTree is not ticking, every bone +## holds still and this returns false. +func _pose_moves(model) -> bool: + var skel: Skeleton3D = model.skeleton + if skel == null or skel.get_bone_count() == 0: + return false + var before: Array = [] + for b in skel.get_bone_count(): + before.append(skel.get_bone_pose_rotation(b)) + for _i in 12: + await process_frame + for b in skel.get_bone_count(): + if not skel.get_bone_pose_rotation(b).is_equal_approx(before[b]): + return true + return false + + +func _check(ok: bool, what: String) -> void: + if ok: + print(" OK: %s" % what) + else: + print(" FAIL: %s" % what) + _fails += 1 + + +func _done() -> void: + print("\n=== CHARACTER PICKER ===\nFailures: %d" % _fails) + quit(1 if _fails > 0 else 0) diff --git a/debug/roster_capture.gd b/debug/roster_capture.gd new file mode 100644 index 0000000..615f717 --- /dev/null +++ b/debug/roster_capture.gd @@ -0,0 +1,84 @@ +extends SceneTree + +## Photograph every character in the picker, one shot each. +## +## The picker builds a real SkinnedPlayerModel per character, so this is the +## cheapest full-roster visual check there is: it catches a rig whose idle pose +## came out wrong, a skin that lost its textures, cloth that never settles, and +## a character standing the wrong way round — none of which any assertion in +## debug/character_picker_check.gd can see. +## +## godot --path . -s res://debug/roster_capture.gd -- + +var _out_dir := "." +var _frames := 0 +var _index := -1 +var _menu: Node = null +## The character currently posing. Tracked explicitly rather than recomputed +## from the list index at shutter time — _next() skips the colour-tint skins, +## so the index when the photo is taken is not the index it was selected at, +## and every file came out labelled with the wrong character. +var _posing: String = "" +## Frames to let a freshly-built character load, blend into idle and let its +## hair and skirt stop swinging before the shutter opens. +const SETTLE := 90 + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + if args.size() > 0: + _out_dir = args[0] + var nm = root.get_node_or_null("NetworkManager") + if nm and nm.has_method("start_singleplayer_match"): + nm.start_singleplayer_match("Deathmatch") + change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") + + +func _process(_delta: float) -> bool: + _frames += 1 + if _frames < 150: + return false + if _menu == null: + _menu = root.get_node_or_null("PauseMenu") + if _menu == null: + print("no PauseMenu autoload") + quit(1) + return true + _menu.visible = true + _menu._show_character() + _next() + return false + if (_frames - 150) % SETTLE == 0: + _shoot() + _next() + return false + + +func _next() -> void: + _index += 1 + while _index < _menu.character_list.item_count: + var id: String = _menu.character_list.get_item_metadata(_index) + # Colour-tint skins have no model; there is nothing to photograph. + _menu.character_list.select(_index) + _menu._on_character_selected(_index) + if _menu._preview_model != null: + _posing = id + return + _index += 1 + _posing = "" + quit() + + +func _shoot() -> void: + if _posing == "": + return + var id := _posing + var img := root.get_texture().get_image() + # Crop to the preview panel — the rest of the frame is the level behind it. + var w := img.get_width() + var h := img.get_height() + img = img.get_region(Rect2i(int(w * 0.44), int(h * 0.15), + int(w * 0.32), int(h * 0.68))) + var path := "%s/roster_%s.png" % [_out_dir, id] + img.save_png(path) + print("wrote %s" % path) diff --git a/debug/ui_capture.gd b/debug/ui_capture.gd new file mode 100644 index 0000000..8d2f2f1 --- /dev/null +++ b/debug/ui_capture.gd @@ -0,0 +1,68 @@ +extends SceneTree + +## Screenshot every UI screen, so the theme can be LOOKED at rather than +## asserted about. +## +## A theme is the one kind of change no headless check can validate: every +## contrast mistake, every panel that turns out to be invisible against the +## thing behind it, and every button whose label sits outside its own chip +## compiles perfectly. So this drives the real screens with rendering on and +## writes a PNG of each. +## +## godot --path . -s res://debug/ui_capture.gd -- +## +## Writes shot_menu.png, shot_pause.png, shot_character.png and +## shot_settings.png. + +var _frames := 0 +var _out_dir := "." + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + if args.size() > 0: + _out_dir = args[0] + change_scene_to_file("res://ui/main_menu/main_menu.tscn") + + +func _process(_delta: float) -> bool: + _frames += 1 + match _frames: + 50: + _shot("menu") + # Into a real match. The pause menu deliberately refuses to open over + # the main menu, and it is the in-game background — a lit 3D level + # rather than a flat colour — that its legibility has to survive. + var nm = root.get_node_or_null("NetworkManager") + if nm and nm.has_method("start_singleplayer_match"): + nm.start_singleplayer_match("Deathmatch") + change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") + 200: + _open("_show_main_menu") + 215: + _shot("pause") + _open("_show_character") + 320: + # Long enough for the model to load and the springs to settle. + _shot("character") + _open("_show_settings") + 340: + _shot("settings") + quit() + return false + + +func _open(screen: String) -> void: + var menu = root.get_node_or_null("PauseMenu") + if menu == null: + push_warning("ui_capture: no PauseMenu autoload") + return + menu.visible = true + menu.call(screen) + + +func _shot(name: String) -> void: + var img := root.get_texture().get_image() + var path := "%s/shot_%s.png" % [_out_dir, name] + img.save_png(path) + print("wrote %s" % path) diff --git a/debug/weapon_lab.tscn b/debug/weapon_lab.tscn index 890d65a..4b0efdd 100644 --- a/debug/weapon_lab.tscn +++ b/debug/weapon_lab.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=2 format=3 uid="uid://cwpnlabweapon01"] +[gd_scene format=3 uid="uid://c1aec1m0pwuk3"] [ext_resource type="Script" path="res://debug/weapon_lab.gd" id="1_lab"] diff --git a/movement/player_movement_controller.gd b/movement/player_movement_controller.gd index 64d7119..4ec6f30 100644 --- a/movement/player_movement_controller.gd +++ b/movement/player_movement_controller.gd @@ -225,6 +225,29 @@ func set_third_person(on: bool) -> void: if wman and "canvas_layer" in wman and is_instance_valid(wman.canvas_layer): wman.canvas_layer.visible = not on +## Swap this player's character model, live. +## +## The only supported way in from outside — the escape menu's character picker +## calls this. It exists because the two halves of a skin change are easy to do +## by halves: `synced_skin_id` is what REMOTE peers rebuild from, and they only +## check it because their `_process` watches for it to change, which the +## authority's does not. Setting the property alone would change every other +## player's view of you and not your own. +## +## Rebuilding drops the old model and its third-person reveal with it, so that +## is re-applied here too; otherwise picking a new character while in third +## person leaves you looking at a shadow. +func set_skin(skin_id: String) -> void: + if skin_id == "" or skin_id == _applied_skin_id: + return + synced_skin_id = skin_id + _apply_skin_model(skin_id) + if third_person: + var visual := get_visual_model() + if visual and visual.has_method("set_owner_visible"): + visual.set_owner_visible(true) + + ## Returns the node that visually represents this player (skinned GLB model ## if the active skin has one, otherwise the procedural HumanoidModel). func get_visual_model() -> Node3D: diff --git a/ui/bolt_rule.gd b/ui/bolt_rule.gd new file mode 100644 index 0000000..039c908 --- /dev/null +++ b/ui/bolt_rule.gd @@ -0,0 +1,71 @@ +extends Control +class_name BoltRule + +## A lightning bolt struck through a horizontal rule. +## +## The motif the whole UI is built around, in one reusable widget: menus use it +## as a section divider, the HUD uses it to underline a heading. It is drawn +## rather than shipped as an image so it takes the theme's colours and scales to +## any width without a second asset. +## +## The zigzag is deliberately not centred. A bolt centred in its rule reads as an +## ornament; struck a third of the way along, it reads as something that HIT the +## line — which is the difference between decorative and energetic. + +@export var line_color: Color = Color(1.0, 0.47, 0.10) +@export var bolt_color: Color = Color(1.0, 0.93, 0.22) +@export var ink: Color = Color(0.045, 0.040, 0.075) +## Where along the rule the bolt strikes, 0..1. +@export var strike_at: float = 0.34 +@export var thickness: float = 3.0 + + +func _init() -> void: + custom_minimum_size = Vector2(0, 22) + mouse_filter = Control.MOUSE_FILTER_IGNORE + + +func _draw() -> void: + var w := size.x + var h := size.y + if w <= 1.0: + return + var mid := h * 0.5 + var strike := w * clampf(strike_at, 0.05, 0.95) + var half := h * 0.42 + + # The rule, broken where the bolt lands so the bolt reads as passing + # THROUGH it rather than sitting on top. + var gap := h * 0.55 + draw_line(Vector2(0, mid), Vector2(maxf(0.0, strike - gap), mid), + line_color, thickness) + draw_line(Vector2(minf(w, strike + gap), mid), Vector2(w, mid), + line_color, thickness) + + # Bolt: down-right, kick back left, down-right again. + var pts := PackedVector2Array([ + Vector2(strike + half * 0.55, mid - half), + Vector2(strike - half * 0.10, mid + half * 0.12), + Vector2(strike + half * 0.30, mid + half * 0.12), + Vector2(strike - half * 0.55, mid + half), + Vector2(strike + half * 0.12, mid - half * 0.10), + Vector2(strike - half * 0.28, mid - half * 0.10), + ]) + # Ink first, one step out, so the bolt keeps a drawn edge against any + # background it is placed on. + draw_colored_polygon(_grown(pts, 2.0), ink) + draw_colored_polygon(pts, bolt_color) + + +## The same polygon pushed out from its own centre — a cheap outline that needs +## no second point list to maintain. +func _grown(pts: PackedVector2Array, by: float) -> PackedVector2Array: + var centre := Vector2.ZERO + for p in pts: + centre += p + centre /= float(pts.size()) + var out := PackedVector2Array() + for p in pts: + var dir := (p - centre) + out.append(p + (dir.normalized() * by if dir.length() > 0.001 else Vector2.ZERO)) + return out diff --git a/ui/bolt_rule.gd.uid b/ui/bolt_rule.gd.uid new file mode 100644 index 0000000..b562f92 --- /dev/null +++ b/ui/bolt_rule.gd.uid @@ -0,0 +1 @@ +uid://bsy66ipfm78ie diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index 10425d4..28676a7 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -4,11 +4,22 @@ var bg: ColorRect var main_vbox: VBoxContainer var resume_btn: Button var respawn_btn: Button +var character_btn: Button var loadouts_btn: Button var settings_btn: Button var return_btn: Button var exit_btn: Button +# Character picker +var character_editor: Control +var character_list: ItemList +var character_desc: Label +var character_back_btn: Button +var _preview_viewport: SubViewport +var _preview_model: SkinnedPlayerModel +var _preview_pivot: Node3D +var _preview_id: String = "" + # Loadout UI var loadout_editor: Control var loadout_list_vbox: VBoxContainer @@ -58,9 +69,15 @@ func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS layer = 100 # Ensure it's on top visible = false - + + # This screen is an autoload, so it can be the FIRST thing the player sees + # styled — opened over a level that never loaded the HUD, it used to render + # in Godot's default grey because it relied on some other screen having + # applied the theme. apply_global is idempotent; just call it. + UITheme.apply_global(get_tree()) _build_ui() _connect_signals() + UITheme.wire_sounds(self) func _input(event: InputEvent) -> void: if waiting_for_input_action != "": @@ -114,6 +131,13 @@ func _build_ui() -> void: bg = ColorRect.new() bg.color = Color(0, 0, 0, 0.7) bg.set_anchors_preset(Control.PRESET_FULL_RECT) + # Assigned here as well as on the root Window, because this screen hangs off + # a CanvasLayer. A Control inherits its theme from its nearest Control + # ANCESTOR, and a CanvasLayer is not one — so the chain breaks here and + # everything below fell back to Godot's default grey. It was not obvious, + # because the parts built with UITheme.title() carry their own overrides and + # looked correct next to a list and a button that did not. + bg.theme = UITheme.build() add_child(bg) # ── Main Menu ── @@ -125,42 +149,48 @@ func _build_ui() -> void: main_vbox.add_theme_constant_override("separation", 20) main_center.add_child(main_vbox) - var title = Label.new() - title.text = "MENU" + var title = UITheme.title("PAUSED", 56) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - title.add_theme_font_size_override("font_size", 48) main_vbox.add_child(title) - + main_vbox.add_child(UITheme.divider(0.62)) + resume_btn = Button.new() resume_btn.text = "Resume" - resume_btn.custom_minimum_size = Vector2(200, 50) + resume_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(resume_btn) - + respawn_btn = Button.new() respawn_btn.text = "Respawn" - respawn_btn.custom_minimum_size = Vector2(200, 50) + respawn_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(respawn_btn) - + + character_btn = Button.new() + character_btn.text = "Character" + character_btn.custom_minimum_size = Vector2(260, 52) + main_vbox.add_child(character_btn) + loadouts_btn = Button.new() loadouts_btn.text = "Loadouts" - loadouts_btn.custom_minimum_size = Vector2(200, 50) + loadouts_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(loadouts_btn) - + settings_btn = Button.new() settings_btn.text = "Settings" - settings_btn.custom_minimum_size = Vector2(200, 50) + settings_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(settings_btn) return_btn = Button.new() return_btn.text = "Main Menu" - return_btn.custom_minimum_size = Vector2(200, 50) + return_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(return_btn) exit_btn = Button.new() exit_btn.text = "Exit Game" - exit_btn.custom_minimum_size = Vector2(200, 50) + exit_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(exit_btn) + _build_character_editor() + # ── Loadout Editor Root ── loadout_editor = Control.new() loadout_editor.set_anchors_preset(Control.PRESET_FULL_RECT) @@ -680,6 +710,213 @@ func _build_ui() -> void: return_dialog.dialog_text = "Are you sure you want to return to the main menu?" add_child(return_dialog) +# ── Character picker ───────────────────────────────────────────────────────── +# +# The list on the left, the character themselves on the right, turning. A name +# in a dropdown is not a character selection screen — you pick a character by +# looking at them, which is the whole reason the models exist. +# +# The preview is a real SkinnedPlayerModel in its own world, not a rendered +# thumbnail, so it shows exactly what will spawn: the same cel look, the same +# per-class outlines, the same cloth and hair on springs. A thumbnail would go +# stale the first time a character was re-imported. + +const PREVIEW_SIZE := Vector2i(520, 640) + +func _build_character_editor() -> void: + character_editor = Control.new() + character_editor.set_anchors_preset(Control.PRESET_FULL_RECT) + character_editor.visible = false + bg.add_child(character_editor) + + var center = CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + character_editor.add_child(center) + + var row = HBoxContainer.new() + row.add_theme_constant_override("separation", 34) + center.add_child(row) + + # Left: the roster + var left = VBoxContainer.new() + left.add_theme_constant_override("separation", 14) + left.custom_minimum_size = Vector2(340, 0) + row.add_child(left) + + var heading := UITheme.title("CHARACTER", 52) + left.add_child(heading) + left.add_child(UITheme.divider(0.28)) + + character_list = ItemList.new() + character_list.custom_minimum_size = Vector2(340, 380) + character_list.auto_height = false + left.add_child(character_list) + + character_desc = UITheme.heading("", 20) + character_desc.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + character_desc.custom_minimum_size = Vector2(340, 60) + left.add_child(character_desc) + + character_back_btn = Button.new() + character_back_btn.text = "Back" + character_back_btn.custom_minimum_size = Vector2(0, 46) + left.add_child(character_back_btn) + + # Right: the character, in a real 3D viewport + var frame := UITheme.card() + row.add_child(frame) + + var vp_container = SubViewportContainer.new() + vp_container.stretch = true + vp_container.custom_minimum_size = Vector2(PREVIEW_SIZE) + frame.add_child(vp_container) + + _preview_viewport = SubViewport.new() + _preview_viewport.own_world_3d = true + _preview_viewport.size = PREVIEW_SIZE + _preview_viewport.msaa_3d = Viewport.MSAA_4X + _preview_viewport.transparent_bg = true + # The tree is paused while this menu is up. Without this the preview would + # be a still frame: no turntable, and — worse — no spring solver, so the + # character's hair and skirt would hang in their bind pose. + _preview_viewport.process_mode = Node.PROCESS_MODE_ALWAYS + vp_container.add_child(_preview_viewport) + + var world = Node3D.new() + _preview_viewport.add_child(world) + + var env = WorldEnvironment.new() + env.environment = LevelEnvironment.make_environment("sunset") + world.add_child(env) + + # Three-point-ish lighting, warm key and cool fill, which is what makes a + # cel-shaded character read as rounded instead of as a sticker. + var key = DirectionalLight3D.new() + key.rotation_degrees = Vector3(-28, 38, 0) + key.light_color = Color(1.0, 0.93, 0.84) + key.light_energy = 1.6 + world.add_child(key) + + var fill = DirectionalLight3D.new() + fill.rotation_degrees = Vector3(-16, -128, 0) + fill.light_color = Color(0.48, 0.62, 1.0) + fill.light_energy = 0.55 + world.add_child(fill) + + _preview_pivot = Node3D.new() + # Turned to face the camera. SkinnedPlayerModel yaws itself 180° on load + # because glTF forward is +Z and players face -Z, which is right in a level + # and means a preview camera sitting on +Z gets the back of the character's + # head. The turntable starts from here. + _preview_pivot.rotation_degrees.y = 180.0 + world.add_child(_preview_pivot) + + var camera = Camera3D.new() + camera.position = Vector3(0, 1.05, 2.65) + camera.rotation_degrees = Vector3(-4, 0, 0) + camera.fov = 38.0 + world.add_child(camera) + + +func _show_character() -> void: + main_vbox.visible = false + loadout_editor.visible = false + settings_editor.visible = false + character_editor.visible = true + _populate_character_list() + + +func _populate_character_list() -> void: + character_list.clear() + var active: String = SkinManager.active_skin_id + var selected := -1 + for id in SkinManager.get_skin_ids(): + var skin = SkinManager.get_skin(id) + if not skin.is_unlocked: + continue + var idx := character_list.add_item(skin.skin_name) + character_list.set_item_metadata(idx, id) + if id == active: + selected = idx + if selected >= 0: + character_list.select(selected) + _on_character_selected(selected) + elif character_list.item_count > 0: + character_list.select(0) + _on_character_selected(0) + + +## Selecting a character equips it immediately — there is no separate confirm. +## +## A picker that needs a second click to take effect makes you compare a preview +## against a memory of the last one. Applying on selection means the character +## behind the menu changes as you arrow through the list, which IS the +## comparison. Nothing here is destructive, so there is nothing to confirm. +func _on_character_selected(index: int) -> void: + if index < 0 or index >= character_list.item_count: + return + var id: String = character_list.get_item_metadata(index) + var skin = SkinManager.get_skin(id) + character_desc.text = skin.description if skin.description != "" else skin.skin_name + _load_preview(id, skin) + SkinManager.set_active_skin(id) + var player := _local_player() + if player and player.has_method("set_skin"): + player.set_skin(id) + + +## Build the turntable model for one skin. A colour-tint skin has no GLB, so the +## preview is emptied rather than left showing whoever was selected before it. +func _load_preview(id: String, skin) -> void: + if id == _preview_id: + return + _preview_id = id + if is_instance_valid(_preview_model): + _preview_model.queue_free() + _preview_model = null + # Every character gets shown from the front. Without this the turntable + # carries on from wherever the last one left it, so the third character you + # look at is side-on and the fourth has their back to you. + if is_instance_valid(_preview_pivot): + _preview_pivot.rotation_degrees.y = 180.0 + if skin == null or skin.model_path == "" \ + or not ResourceLoader.exists(skin.model_path): + return + var model := SkinnedPlayerModel.new() + model.model_path = skin.model_path + model.skin_id = id + # Not first-person and not shadows-only: this is the one place the local + # player is supposed to see their own character in full. + model.first_person_mode = false + model.shadows_only = false + model.process_mode = Node.PROCESS_MODE_ALWAYS + _preview_pivot.add_child(model) + _preview_model = model + + +func _process(delta: float) -> void: + # Turntable. Only while the picker is actually on screen — the rest of the + # time this node is an invisible autoload and has no business spending + # frames. + if character_editor and character_editor.visible and is_instance_valid(_preview_pivot): + _preview_pivot.rotate_y(delta * 0.45) + + +## The player this menu belongs to. Spawners differ between the built levels and +## the test harness, so both places are checked. +func _local_player() -> Node: + var pid := str(multiplayer.get_unique_id()) + var scene := get_tree().current_scene + if scene == null: + return null + var found := scene.get_node_or_null(pid) + if found == null: + var spawner := scene.get_node_or_null("PlayerSpawner") + if spawner: + found = spawner.get_node_or_null(pid) + return found + + func _build_vol_control(parent: Node, label_name: String) -> Array: var hbox = HBoxContainer.new() parent.add_child(hbox) @@ -725,6 +962,9 @@ func _create_label(text: String) -> Label: func _connect_signals() -> void: resume_btn.pressed.connect(_resume) respawn_btn.pressed.connect(_respawn) + character_btn.pressed.connect(_show_character) + character_back_btn.pressed.connect(_show_main_menu) + character_list.item_selected.connect(_on_character_selected) loadouts_btn.pressed.connect(_show_loadouts) settings_btn.pressed.connect(_show_settings) return_btn.pressed.connect(_show_return_dialog) @@ -753,10 +993,12 @@ func _show_main_menu() -> void: main_vbox.visible = true loadout_editor.visible = false settings_editor.visible = false + character_editor.visible = false func _show_loadouts() -> void: main_vbox.visible = false settings_editor.visible = false + character_editor.visible = false loadout_editor.visible = true edit_panel.visible = false _populate_loadout_list() @@ -790,6 +1032,7 @@ func _populate_audio_ui() -> void: func _show_settings() -> void: main_vbox.visible = false loadout_editor.visible = false + character_editor.visible = false settings_editor.visible = true _populate_keybindings() _populate_video_ui() diff --git a/ui/ui_theme.gd b/ui/ui_theme.gd index 97c155f..92726bc 100644 --- a/ui/ui_theme.gd +++ b/ui/ui_theme.gd @@ -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)