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"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user