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:
Nicholas Butzke
2026-07-27 14:46:14 -04:00
co-authored by Claude Opus 5
parent 53f175ed6d
commit 26f7c2c622
9 changed files with 850 additions and 79 deletions
+23
View File
@@ -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: