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
+123
View File
@@ -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)