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
@@ -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)
|
||||
@@ -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 -- <output_dir>
|
||||
|
||||
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)
|
||||
@@ -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 -- <output_dir>
|
||||
##
|
||||
## 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)
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bsy66ipfm78ie
|
||||
+257
-14
@@ -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()
|
||||
|
||||
+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