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]>
227 lines
7.2 KiB
GDScript
227 lines
7.2 KiB
GDScript
extends SceneTree
|
|
|
|
## Dev tool: screenshot every animation state on the active GLB skin.
|
|
## Run:
|
|
## godot --path . --windowed --resolution 1280x720 -s res://debug/anim_capture.gd -- <out_dir> [skin_id]
|
|
## Saves anim_<state>.png per state plus mid-action shots for reload/throw.
|
|
|
|
var _frames := 0
|
|
var _out_dir := "."
|
|
var _skin := "miku"
|
|
var _player: Node = null
|
|
var _model: Node = null
|
|
var _cam: Camera3D = null
|
|
|
|
# [tag, state, speed, crouch, hold_frames]
|
|
var _states := [
|
|
["idle", "idle", 0.0, false, 50],
|
|
["walk", "ground", 2.2, false, 50],
|
|
["run", "ground", 6.5, false, 50],
|
|
["sprint", "ground", 12.0, false, 50],
|
|
["crouch", "ground", 0.0, true, 50],
|
|
["crouchwalk", "ground", 2.0, true, 50],
|
|
["fall", "air", 4.0, false, 50],
|
|
["slide", "slide", 10.0, true, 50],
|
|
["dash", "dash", 14.0, false, 30],
|
|
["wallrun", "wall_run", 9.0, false, 50],
|
|
["grapple", "grapple", 10.0, false, 50],
|
|
["dance", "idle", 0.0, false, 60],
|
|
]
|
|
var _phase := 0 # index into _states, then actions after
|
|
var _phase_frame := 0
|
|
var _actions := [["reload", 30], ["throw", 18]]
|
|
var _action_i := 0
|
|
var _mode := "states" # states -> actions -> ads -> done
|
|
|
|
|
|
func _initialize() -> void:
|
|
var args := OS.get_cmdline_user_args()
|
|
if args.size() > 0:
|
|
_out_dir = args[0]
|
|
if args.size() > 1:
|
|
_skin = args[1]
|
|
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
|
|
|
|
|
|
func _process(_delta: float) -> bool:
|
|
_frames += 1
|
|
if _frames == 40:
|
|
var sm = root.get_node_or_null("SkinManager")
|
|
if sm:
|
|
sm.set_active_skin(_skin)
|
|
var nm = root.get_node_or_null("NetworkManager")
|
|
if nm and nm.has_method("start_singleplayer_match"):
|
|
nm.start_singleplayer_match(GameMode.DEATHMATCH)
|
|
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
|
|
return false
|
|
if _frames < 160:
|
|
return false
|
|
if _frames == 160:
|
|
for p in root.find_children("*", "CharacterBody3D", true, false):
|
|
if p.has_method("get_visual_model") and p.is_multiplayer_authority():
|
|
_player = p
|
|
break
|
|
if not _player:
|
|
printerr("anim_capture: no player found")
|
|
return true
|
|
_model = _player.get_node_or_null("SkinnedModel")
|
|
if not _model:
|
|
printerr("anim_capture: no SkinnedModel (skin '%s' active?)" % _skin)
|
|
return true
|
|
# Freeze gameplay driving so we control the animation state directly,
|
|
# and teleport to open ground so walls never block the shot.
|
|
_player.set_physics_process(false)
|
|
_player.set_process(false)
|
|
_player.global_position = Vector3(0, 1.2, 14)
|
|
_player.rotation = Vector3.ZERO
|
|
_model.set_owner_visible(true)
|
|
# Give the model a weapon so armed poses read.
|
|
if _model.has_method("set_weapon"):
|
|
_model.set_weapon("res://weapons/ak47.gd")
|
|
# Camera: repositioned per shot (front + side). Model faces -Z.
|
|
_cam = Camera3D.new()
|
|
_cam.cull_mask &= ~(1 << 19) # don't render the FP viewmodel layer
|
|
current_scene.add_child(_cam)
|
|
_cam_to(Vector3(0.4, 0.9, -2.4))
|
|
_cam.current = true
|
|
return false
|
|
|
|
# Drive the forced state every frame so blends settle. Front shot two
|
|
# frames before the hold ends, side shot on the last frame (the camera
|
|
# hop needs a real rendered frame in between).
|
|
if _mode == "states":
|
|
var s: Array = _states[_phase]
|
|
if s[0] == "dance" and _model.has_method("set_dancing"):
|
|
_model.set_dancing(true)
|
|
_model.update_state(s[1], s[2], s[3])
|
|
_phase_frame += 1
|
|
var hold: int = int(s[4])
|
|
if _phase_frame == hold - 2:
|
|
_snap("anim_" + String(s[0]) + "_f")
|
|
_cam_to(Vector3(2.4, 0.9, -0.4))
|
|
elif _phase_frame >= hold:
|
|
_snap("anim_" + String(s[0]) + "_s")
|
|
_cam_to(Vector3(0.4, 0.9, -2.4))
|
|
if s[0] == "dance" and _model.has_method("set_dancing"):
|
|
_model.set_dancing(false)
|
|
if s[0] == "idle":
|
|
_debug_gun()
|
|
_phase_frame = 0
|
|
_phase += 1
|
|
if _phase >= _states.size():
|
|
_mode = "actions"
|
|
return false
|
|
if _mode == "actions":
|
|
var a: Array = _actions[_action_i]
|
|
if _phase_frame == 0:
|
|
_model.play_action(a[0])
|
|
_model.update_state("idle", 0.0, false)
|
|
_phase_frame += 1
|
|
var hold_a: int = int(a[1])
|
|
if _phase_frame == hold_a - 2:
|
|
_snap("anim_" + String(a[0]) + "_f")
|
|
_cam_to(Vector3(2.4, 0.9, -0.4))
|
|
elif _phase_frame >= hold_a:
|
|
_snap("anim_" + String(a[0]) + "_s")
|
|
_cam_to(Vector3(0.4, 0.9, -2.4))
|
|
_phase_frame = 0
|
|
_action_i += 1
|
|
if _action_i >= _actions.size():
|
|
_mode = "ads"
|
|
return false
|
|
if _mode == "ads":
|
|
_model.update_state("idle", 0.0, false)
|
|
if _model.has_method("set_locomotion"):
|
|
_model.set_locomotion(0.0, 0.0, 1.0)
|
|
_phase_frame += 1
|
|
if _phase_frame == 108:
|
|
_snap("anim_ads_f")
|
|
_cam_to(Vector3(2.4, 0.9, -0.4))
|
|
elif _phase_frame >= 110:
|
|
_snap("anim_ads_s")
|
|
_debug_gun()
|
|
_phase_frame = 0
|
|
_mode = "recoil"
|
|
return false
|
|
# Sustained fire: the shot kick must visibly move the third-person model.
|
|
if _mode == "recoil":
|
|
_model.update_state("idle", 0.0, false)
|
|
if _model.has_method("set_locomotion"):
|
|
_model.set_locomotion(0.0, 0.0, 0.0)
|
|
_phase_frame += 1
|
|
if _phase_frame == 40:
|
|
_snap("anim_recoil_before_f")
|
|
elif _phase_frame == 41:
|
|
_model.add_gun_recoil() # a single shot
|
|
elif _phase_frame == 42:
|
|
_snap("anim_recoil_peak_f")
|
|
_cam_to(Vector3(2.4, 0.9, -0.4))
|
|
elif _phase_frame == 44:
|
|
_model.add_gun_recoil()
|
|
_snap("anim_recoil_peak_s")
|
|
_cam_to(Vector3(0.4, 0.9, -2.4))
|
|
_phase_frame = 0
|
|
_mode = "combo"
|
|
return false
|
|
# Mechanic-fidelity combos: reload WHILE sliding (legs must keep sliding),
|
|
# then the grapple zip pose toward an actual anchor point.
|
|
if _mode == "combo":
|
|
if _model.has_method("set_locomotion"):
|
|
_model.set_locomotion(0.0, 0.0, 0.0)
|
|
_model.update_state("slide", 10.0, true)
|
|
if _phase_frame == 10:
|
|
_model.play_action("reload")
|
|
_phase_frame += 1
|
|
if _phase_frame == 40:
|
|
_snap("anim_slide_reload_f")
|
|
_cam_to(Vector3(2.4, 0.9, -0.4))
|
|
elif _phase_frame >= 42:
|
|
_snap("anim_slide_reload_s")
|
|
_phase_frame = 0
|
|
_mode = "grapple_zip"
|
|
return false
|
|
if _mode == "grapple_zip":
|
|
if _phase_frame == 0 and _model._anim_tree:
|
|
# End the reload one-shot so the zip pose is unpolluted.
|
|
_model._anim_tree.set("parameters/upper/request",
|
|
AnimationNodeOneShot.ONE_SHOT_REQUEST_ABORT)
|
|
_model.update_state("grapple", 12.0, false)
|
|
if _model.has_method("set_grapple_target"):
|
|
# Anchor up and ahead of the player (player faces -Z world).
|
|
_model.set_grapple_target(_player.global_position + Vector3(1.5, 7.0, -7.0))
|
|
_phase_frame += 1
|
|
if _phase_frame == 48:
|
|
_snap("anim_grapple_zip_f")
|
|
_cam_to(Vector3(2.4, 0.9, -0.4))
|
|
elif _phase_frame >= 50:
|
|
_snap("anim_grapple_zip_s")
|
|
return true
|
|
return false
|
|
return true
|
|
|
|
|
|
func _debug_gun() -> void:
|
|
var pm = _model.get("_pose_mod")
|
|
if not pm:
|
|
return
|
|
var skel: Skeleton3D = _model.skeleton
|
|
var hand := skel.find_bone("DEF-hand.R")
|
|
var achieved := Vector3.ZERO
|
|
if hand >= 0 and pm.gun_fwd_hand.length_squared() > 0.01:
|
|
achieved = skel.get_bone_global_pose(hand).basis * pm.gun_fwd_hand
|
|
print("GUN DEBUG: fwd_hand=", pm.gun_fwd_hand, " ads=", pm.ads,
|
|
" hold_r=", pm._hold_r, " achieved_world_fwd=", achieved)
|
|
|
|
|
|
func _cam_to(offset: Vector3) -> void:
|
|
var base: Vector3 = _player.global_position
|
|
_cam.global_position = base + offset
|
|
_cam.look_at(base + Vector3(0, 0.25, 0), Vector3.UP)
|
|
|
|
|
|
func _snap(tag: String) -> void:
|
|
var img := root.get_viewport().get_texture().get_image()
|
|
var path := _out_dir + "/" + tag + ".png"
|
|
img.save_png(path)
|
|
print("anim_capture: saved ", path)
|