Files
Papay-Shooter/debug/spawn_smoke_test.gd
Nicholas ButzkeandClaude Opus 5 2efc21b18d feat(menu): PLAY is one press, and the level cards are photographs
Reaching a game was Singleplayer → pick a level. Reaching a multiplayer game
was Multiplayer → Host Game → wait → pick a level → wait, and the gamemode
dropdown had exactly one entry which set a string nothing read.

PLAY now starts the last map and mode played, and says which — "Akiba Crossing
• Deathmatch" under the button, so pressing it is a promise rather than a leap.
Everything else on the home screen is a detour from that, which is the right
shape for a menu: the common case is a button, not a path. Hosting is one press
and lands in the lobby already hosting; the lobby puts map, mode, players and
start on ONE screen; Enter connects, so typing an address does not then require
reaching for the mouse.

This is the lesson HoYoverse published about Zenless Zone Zero's first months
more loudly than anything else they have written. Their postmortem on the TV
mode names three complaints — it took too long, it sat between the player and
the combat, and there was too much of it early — and 1.2 removed it from the
story entirely rather than shortening it. Time spent BEFORE the thing the
player came for is not neutral, it is a cost.

The cards were a two-stop gradient generated from two colours in a meta file,
whose hover state was `use_hdr = true` — not a visible change on any of them.
They are photographs now, shot by debug/map_preview_capture.gd, which took
three attempts to get right and each attempt is a comment in the file:

  - framing each map from OUTSIDE by merging every VisualInstance3D's AABB
    produced five tiny dioramas floating on a table, and one solid black
    rectangle. That is a minimap, and a minimap is not a photograph.
  - standing at a spawn point fixed three maps and left two black: fps_blockout
    is genuinely dark and most of its spawns face an unlit wall, and
    procedural_arena builds its geometry at runtime so no fixed offset is
    reliably inside it.
  - so the tool now RENDERS several vantages and scores each result — mean
    luminance times its standard deviation, because brightness alone picks the
    empty sky and variance alone picks a high-contrast corner of a dark room.
    Their product picks a photograph. A black rectangle passes any check that
    only asks whether the camera ended up somewhere sensible.

Also fixed, and caught by looking at the screenshot: the selected mode chip was
unreadable. Its glyph is ink on volt, and the theme gave every button a 5 px INK
outline — an ink glyph inside an ink outline is not outlined, it is five pixels
fatter, and on a small chip that is a solid blob. Button labels now carry no
outline at all, which is the same reasoning debug/ui_contrast_check.gd already
encodes: an outline separates a glyph from a backdrop it cannot beat alone, and
a label on a solid chip does not have that problem. The chips keep their heavy
ink border, so nothing about the drawn look changes.

`current_gamemode` is normalised on the way in, because it used to hold a
display string and six callers still pass one. An unrecognised id compares
unequal to GameMode.GUN_GAME and would silently disable that mode's weapon
issuing — half a mode is worse than none.

The menu's background character is the player's OWN skin holding their weapon,
rather than the box-and-capsule mannequin that stood there before.

spawn smoke 0, game modes 30/30, movement 11/11, contrast 108/108, HUD layout
PASS, weapon holds 0, dances 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:40:50 -04:00

141 lines
4.8 KiB
GDScript

extends SceneTree
## Headless smoke test for the player spawn + skin + animation pipeline.
## Boots the test level as a singleplayer match, spawns the local player,
## ticks frames, and verifies the visual model came up for both the
## procedural skin and a GLB skin.
##
## Run: godot --headless --path . -s res://debug/spawn_smoke_test.gd
var _failures: Array = []
func _init() -> void:
call_deferred("_run")
func _run() -> void:
await process_frame
await process_frame
var skin_mgr = root.get_node_or_null("/root/SkinManager")
_check(skin_mgr != null, "SkinManager autoload exists")
var audio_mgr = root.get_node_or_null("/root/AudioManager")
_check(audio_mgr != null, "AudioManager autoload exists")
if audio_mgr:
_check(audio_mgr.has_sound("ak47_fire"), "AudioManager auto-registered ak47_fire")
# Pick the GLB skin if its model file is present, else default.
var glb_skin_id := ""
if skin_mgr:
for id in skin_mgr.get_skin_ids():
var s = skin_mgr.get_skin(id)
if s.model_path != "" and ResourceLoader.exists(s.model_path):
glb_skin_id = id
break
await _test_spawn_with_skin("default", false)
if glb_skin_id != "":
await _test_spawn_with_skin(glb_skin_id, true)
else:
print("NOTE: no GLB skin available, skipped skinned model test")
print("\n=== SPAWN SMOKE SUMMARY ===")
print("Failures: %d" % _failures.size())
for f in _failures:
print("FAIL: ", f)
quit(0 if _failures.is_empty() else 1)
func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
print("\n--- Spawn test with skin '%s' ---" % skin_id)
var skin_mgr = root.get_node_or_null("/root/SkinManager")
if skin_mgr:
skin_mgr.set_active_skin(skin_id)
var nm = root.get_node_or_null("/root/NetworkManager")
if not _check(nm != null, "NetworkManager autoload exists"):
return
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
# Let the level build and the player spawn + settle.
for i in 30:
await process_frame
var level = current_scene
if not _check(level != null, "level scene loaded"):
return
var player = _find_player(level)
if not _check(player != null, "player '1' spawned"):
return
_check(player.get_node_or_null("MovementStateMachine") != null, "state machine present")
var visual = player.get_visual_model() if player.has_method("get_visual_model") else null
_check(visual != null, "visual model present")
if expect_skinned:
var skinned = player.get_node_or_null("SkinnedModel")
if _check(skinned != null, "SkinnedModel created for GLB skin"):
_check(skinned.loaded, "GLB model loaded")
_check(skinned.skeleton != null, "skeleton found in GLB")
# The mesh must be bound to the skeleton, else it renders its bind
# pose (T-pose) while the skeleton animates invisibly.
var bound := false
if skinned.skeleton:
for mi in skinned.find_children("*", "MeshInstance3D", true, false):
if mi.skin != null and mi.get_node_or_null(mi.skeleton) == skinned.skeleton:
bound = true
_check(bound, "skinned mesh is bound to the skeleton (won't T-pose)")
if skinned.animation_player:
# The AnimationTree drives playback now; the raw player is idle.
_check(skinned._anim_tree != null and skinned._anim_tree.active,
"animation tree active")
print(" clips resolved: ", skinned._resolved_clips)
# Drive some movement states through the same API the game uses.
for state in ["ground", "air", "slide", "wall_run", "dash"]:
skinned.update_state(state, 9.0, false)
await process_frame
_check(true, "state cycling did not crash")
# Third-person toggle: camera swap + owner model visibility.
if player.has_method("set_third_person"):
var tp_cam = player.get_node_or_null(
"HeadPivot/OrbitPivot/ShoulderOffset/ThirdPersonBoom/ThirdPersonCamera")
_check(tp_cam != null, "third-person camera boom created")
player.set_third_person(true)
await process_frame
_check(player.third_person, "toggled into third person")
if tp_cam:
_check(tp_cam.current, "third-person camera is current when toggled on")
player.set_third_person(false)
await process_frame
_check(not player.third_person, "toggled back to first person")
else:
var humanoid = player.get_node_or_null("HumanoidModel")
_check(humanoid != null and humanoid.visible, "procedural model visible for color skin")
# Simulate a few physics frames of idle play.
for i in 30:
await physics_frame
_check(is_instance_valid(player), "player survived 30 physics frames")
func _find_player(node: Node) -> Node:
if node is CharacterBody3D and node.name == "1" and node.has_method("get_visual_model"):
return node
for child in node.get_children():
var found := _find_player(child)
if found:
return found
return null
func _check(ok: bool, msg: String) -> bool:
if ok:
print(" OK: ", msg)
else:
print(" FAIL: ", msg)
_failures.append(msg)
return ok