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
+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()
|
||||
|
||||
Reference in New Issue
Block a user