ani
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
extends SceneTree
|
||||
|
||||
## Rendered character-art gate for the focused Sakura second-skin pilot.
|
||||
## Captures open/deploying/closed helmet states and verifies that active jet
|
||||
## flames emerge from all four integrated vector-jet mouths.
|
||||
|
||||
var _scene: Node3D
|
||||
var _camera: Camera3D
|
||||
var _models: Array[SkinnedPlayerModel] = []
|
||||
var _jets: Array[PlayerJetVFX] = []
|
||||
var _out_dir := "."
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
if not args.is_empty():
|
||||
_out_dir = args[0]
|
||||
if not _out_dir.is_absolute_path():
|
||||
_out_dir = ProjectSettings.globalize_path(_out_dir)
|
||||
DirAccess.make_dir_recursive_absolute(_out_dir)
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_scene = Node3D.new()
|
||||
root.add_child(_scene)
|
||||
current_scene = _scene
|
||||
_build_studio()
|
||||
_add_pilot("default", "sakura",
|
||||
"res://assets/characters/skins/sakura_pilot_hd.glb", 0.0)
|
||||
for _i in 24:
|
||||
await process_frame
|
||||
for model in _models:
|
||||
model.update_state("ground", 0.0, false)
|
||||
model.set_locomotion(0.0, 1.0, 0.0)
|
||||
|
||||
_camera.global_position = Vector3(0.0, 1.05, -3.2)
|
||||
_camera.look_at(Vector3(0.0, 0.92, 0.0), Vector3.UP)
|
||||
await _capture("pilot_open")
|
||||
_camera.global_position = Vector3(0.0, 1.58, -1.15)
|
||||
_camera.look_at(Vector3(0.0, 1.57, 0.0), Vector3.UP)
|
||||
await _capture("helmet_open_closeup")
|
||||
_models[0].set_helmet_closed(true)
|
||||
for _i in 5:
|
||||
await process_frame
|
||||
await _capture("pilot_helmet_deploy")
|
||||
for _i in 40:
|
||||
await process_frame
|
||||
await _capture("pilot_helmet_closed")
|
||||
|
||||
_camera.global_position = Vector3(0.0, 1.08, 3.2)
|
||||
_camera.look_at(Vector3(0.0, 0.92, 0.0), Vector3.UP)
|
||||
await _capture("pilot_rear")
|
||||
for jet in _jets:
|
||||
jet.burst_double_jump()
|
||||
for _i in 4:
|
||||
await process_frame
|
||||
await _capture("pilot_thrusters")
|
||||
|
||||
_models[0].set_wall_side(-1.0)
|
||||
_models[0].set_wall_glide_motion(Vector3.RIGHT * 13.0)
|
||||
for _i in 24:
|
||||
_models[0].update_state("wall_run", 13.0, false)
|
||||
_jets[0].set_wall_glide(true, Vector2.RIGHT)
|
||||
await process_frame
|
||||
_camera.global_position = Vector3(2.4, 1.45, -2.7)
|
||||
_camera.look_at(Vector3(0.0, 1.0, 0.0), Vector3.UP)
|
||||
await _capture("pilot_wall_glide")
|
||||
|
||||
var failed := false
|
||||
if not _models[0].helmet_closed_debug() \
|
||||
or _models[0].helmet_progress_debug() < 0.98:
|
||||
push_error("ANIME_MECHA_CAPTURE helmet failed to finish deployment")
|
||||
failed = true
|
||||
for model_index in _models.size():
|
||||
var model := _models[model_index]
|
||||
var resolved_roles: Array[String] = []
|
||||
var missing_roles: Array[String] = []
|
||||
for role in ["hips", "shoulder.L", "shoulder.R", "upper_arm.L",
|
||||
"upper_arm.R", "forearm.L", "forearm.R", "thigh.L",
|
||||
"thigh.R", "shin.L", "shin.R", "foot.L", "foot.R"]:
|
||||
if model._role_bone(role, []) >= 0:
|
||||
resolved_roles.append(role)
|
||||
else:
|
||||
missing_roles.append(role)
|
||||
print("ANIME_MECHA_CAPTURE %s: %d detail parts, %d nozzles, " % [
|
||||
model.skin_id, model.mecha_detail_part_count_debug(),
|
||||
model.mecha_nozzle_count_debug()], "resolved=", resolved_roles,
|
||||
" missing=", missing_roles)
|
||||
if not missing_roles.is_empty():
|
||||
var bone_names: Array[String] = []
|
||||
if model.skeleton:
|
||||
for bone_index in model.skeleton.get_bone_count():
|
||||
bone_names.append(model.skeleton.get_bone_name(bone_index))
|
||||
print("ANIME_MECHA_CAPTURE unresolved sidecar roles=",
|
||||
model._rig_info.get("roles", {}), " skeleton bones=", bone_names)
|
||||
if model.mecha_detail_part_count_debug() < 180:
|
||||
push_error("ANIME_MECHA_CAPTURE insufficient hardware detail on %s: %d parts" % [
|
||||
model.skin_id, model.mecha_detail_part_count_debug()])
|
||||
failed = true
|
||||
if model.mecha_nozzle_count_debug() != 4:
|
||||
push_error("ANIME_MECHA_CAPTURE missing four visible nozzles on %s" % model.skin_id)
|
||||
failed = true
|
||||
var nozzles := model.get_jet_socket_world_positions()
|
||||
var flames := _jets[model_index].get_flame_world_positions()
|
||||
if nozzles.size() != 4 or flames.size() != 4:
|
||||
push_error("ANIME_MECHA_CAPTURE socket count mismatch on %s" % model.skin_id)
|
||||
failed = true
|
||||
continue
|
||||
for nozzle_index in 4:
|
||||
if nozzles[nozzle_index].distance_to(flames[nozzle_index]) > 0.001:
|
||||
push_error("ANIME_MECHA_CAPTURE flame %d misses %s nozzle" % [
|
||||
nozzle_index, model.skin_id])
|
||||
failed = true
|
||||
print("ANIME_MECHA_CAPTURE %s" % ("FAIL" if failed else "PASS"))
|
||||
quit(1 if failed else 0)
|
||||
|
||||
|
||||
func _build_studio() -> void:
|
||||
var world := WorldEnvironment.new()
|
||||
var environment := Environment.new()
|
||||
environment.background_mode = Environment.BG_COLOR
|
||||
environment.background_color = Color("b8c0cc")
|
||||
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
environment.ambient_light_color = Color("e8edf6")
|
||||
environment.ambient_light_energy = 1.15
|
||||
environment.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
||||
world.environment = environment
|
||||
_scene.add_child(world)
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.rotation_degrees = Vector3(-42.0, -28.0, 0.0)
|
||||
sun.light_energy = 1.9
|
||||
_scene.add_child(sun)
|
||||
_camera = Camera3D.new()
|
||||
_camera.fov = 38.0
|
||||
_scene.add_child(_camera)
|
||||
_camera.current = true
|
||||
|
||||
|
||||
func _add_pilot(id: String, theme: String, path: String, x: float) -> void:
|
||||
var model := SkinnedPlayerModel.new()
|
||||
model.skin_id = id
|
||||
model.mecha_theme = theme
|
||||
model.model_path = path
|
||||
model.position.x = x
|
||||
_scene.add_child(model)
|
||||
# Static art QA does not need the expensive full-quality hair/cloth solver;
|
||||
# gameplay and the picker still use their normal distance-driven LOD.
|
||||
if model._spring_mod:
|
||||
model._spring_mod.lod = 3
|
||||
model._spring_mod = null
|
||||
_models.append(model)
|
||||
var jet := PlayerJetVFX.new()
|
||||
jet.name = "JetVFX_%s" % id
|
||||
_scene.add_child(jet)
|
||||
jet.set_visual_model(model)
|
||||
jet.set_render_enabled(true)
|
||||
_jets.append(jet)
|
||||
|
||||
|
||||
func _capture(tag: String) -> void:
|
||||
for _i in 5:
|
||||
await process_frame
|
||||
var path := _out_dir.path_join(tag + ".png")
|
||||
root.get_texture().get_image().save_png(path)
|
||||
print("ANIME_MECHA_CAPTURE saved ", path)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctjjlkxakx4ss
|
||||
@@ -1,11 +1,10 @@
|
||||
extends SceneTree
|
||||
|
||||
## Does the escape menu's character picker actually work?
|
||||
## Does character selection work as part of every loadout?
|
||||
##
|
||||
## 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.
|
||||
## The character picker used to be a separate global screen. It now belongs to
|
||||
## each loadout, so this checks the full-screen editor, saved character fields,
|
||||
## selected-item summary, and animated preview together.
|
||||
##
|
||||
## godot --headless --path . -s res://debug/character_picker_check.gd
|
||||
|
||||
@@ -22,29 +21,41 @@ func _init() -> void:
|
||||
_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()
|
||||
menu.visible = true
|
||||
menu._show_loadouts()
|
||||
await process_frame
|
||||
_check(menu.character_editor.visible, "Character screen shows")
|
||||
_check(menu.loadout_editor.visible, "Loadout editor shows")
|
||||
_check(not menu.main_vbox.visible, "Main pause list hides behind it")
|
||||
_check(menu.loadout_editor.anchor_right == 1.0 and menu.loadout_editor.anchor_bottom == 1.0,
|
||||
"Loadout editor is anchored full-screen")
|
||||
_check(menu.loadout_list_vbox.get_child_count() == 5, "All five loadouts are shown")
|
||||
_check(menu.skin_opt != null and menu.skin_opt.item_count > 0,
|
||||
"Character selector is inside the loadout editor")
|
||||
|
||||
# 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 loadout_mgr = root.get_node("LoadoutManager")
|
||||
for loadout_index in loadout_mgr.loadouts.size():
|
||||
var loadout: Dictionary = loadout_mgr.loadouts[loadout_index]
|
||||
_check(loadout.has("skin"), "'%s' stores its own character" % loadout.get("name", "Loadout"))
|
||||
menu._edit_loadout(loadout_index)
|
||||
_check(menu._selected_metadata(menu.skin_opt) == str(loadout.get("skin", "default")),
|
||||
"'%s' opens with its saved character selected" % loadout.get("name", "Loadout"))
|
||||
_check(menu.loadout_summary.text.contains(menu._weapon_name(str(loadout.get("primary_1", "none")))),
|
||||
"'%s' summary displays its selected items" % loadout.get("name", "Loadout"))
|
||||
|
||||
var count: int = menu.character_list.item_count
|
||||
var count: int = menu.skin_opt.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)
|
||||
var id: String = menu.skin_opt.get_item_metadata(i)
|
||||
menu.skin_opt.select(i)
|
||||
menu._on_skin_option_selected(i)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_check(menu.character_desc.text != "", "'%s' has a description line" % id)
|
||||
_check(menu.loadout_summary.text.contains(skin_mgr.get_skin(id).skin_name),
|
||||
"'%s' appears in the selected-item summary" % id)
|
||||
|
||||
var skin = skin_mgr.get_skin(id)
|
||||
var expects_model: bool = skin.model_path != "" \
|
||||
@@ -78,12 +89,11 @@ func _init() -> void:
|
||||
_check(await _pose_moves(model),
|
||||
"'%s' preview skeleton is actually animating" % id)
|
||||
|
||||
_check(seen_glb >= 6, "every shipping GLB skin previewed (%d)" % seen_glb)
|
||||
_check(seen_glb > 0, "shipping GLB character previews were built (%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(not menu.loadout_editor.visible, "Back closes the loadout editor")
|
||||
_check(menu.main_vbox.visible, "Pause list is showing again")
|
||||
|
||||
_done()
|
||||
@@ -119,5 +129,5 @@ func _check(ok: bool, what: String) -> void:
|
||||
|
||||
|
||||
func _done() -> void:
|
||||
print("\n=== CHARACTER PICKER ===\nFailures: %d" % _fails)
|
||||
print("\n=== LOADOUT CHARACTER SELECTOR ===\nFailures: %d" % _fails)
|
||||
quit(1 if _fails > 0 else 0)
|
||||
|
||||
@@ -5,6 +5,7 @@ extends SceneTree
|
||||
|
||||
var _frames := 0
|
||||
var _out_dir := "."
|
||||
var _skin_id := "default"
|
||||
var _player: Node = null
|
||||
var _model: Node = null
|
||||
var _jets: PlayerJetVFX = null
|
||||
@@ -17,6 +18,8 @@ func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
if not args.is_empty():
|
||||
_out_dir = args[0]
|
||||
if args.size() > 1:
|
||||
_skin_id = args[1]
|
||||
if not _out_dir.is_absolute_path():
|
||||
_out_dir = ProjectSettings.globalize_path(_out_dir)
|
||||
var err := DirAccess.make_dir_recursive_absolute(_out_dir)
|
||||
@@ -32,7 +35,7 @@ func _process(_delta: float) -> bool:
|
||||
if _frames == 35:
|
||||
var skins = root.get_node_or_null("SkinManager")
|
||||
if skins:
|
||||
skins.set_active_skin("miku")
|
||||
skins.set_active_skin(_skin_id)
|
||||
var network = root.get_node_or_null("NetworkManager")
|
||||
if network and network.has_method("start_singleplayer_match"):
|
||||
network.start_singleplayer_match(GameMode.DEATHMATCH)
|
||||
|
||||
+1
-15
@@ -33,22 +33,8 @@ func _run() -> void:
|
||||
_expect(dash_exhaust.z > 0.90, "forward dash exhaust must point backward")
|
||||
_expect(dash_exhaust.y < 0.0, "dash exhaust keeps a slight downward cant")
|
||||
|
||||
var humanoid := HumanoidModel.new()
|
||||
host.add_child(humanoid)
|
||||
await process_frame
|
||||
humanoid.set_locomotion(0.0, -1.0, 0.0)
|
||||
humanoid.update_state("ground", 9.0)
|
||||
humanoid._process(0.1)
|
||||
_expect(humanoid.root_pivot.rotation.x > 0.0,
|
||||
"fallback model must lean backward while backpedalling")
|
||||
humanoid.update_state("ground", 5.0)
|
||||
humanoid._process(0.1)
|
||||
_expect(humanoid._brake_left > 0.0, "deceleration must arm the braking pose")
|
||||
_expect(absf(humanoid.thigh_r_pivot.rotation.x) > 0.05,
|
||||
"braking pose must plant a lead foot")
|
||||
|
||||
if _failures.is_empty():
|
||||
print("JET_POSE_TEST PASS: four flames, directional thrust, reverse lean, brake pose")
|
||||
print("JET_POSE_TEST PASS: four flames and directional thrust")
|
||||
quit(0)
|
||||
else:
|
||||
for failure in _failures:
|
||||
|
||||
@@ -18,7 +18,8 @@ var _camera: Camera3D = null
|
||||
var _motion_frame := 0
|
||||
var _stop_frame := -1
|
||||
var _worst_run_stride_error := 0.0
|
||||
var _worst_stop_stride := 0.0
|
||||
var _first_stop_stride := 0.0
|
||||
var _settled_stop_stride := 0.0
|
||||
var _failures: Array[String] = []
|
||||
|
||||
|
||||
@@ -62,8 +63,9 @@ func _process(_delta: float) -> bool:
|
||||
if _motion_frame <= RUN_FRAMES:
|
||||
_model.set_locomotion(0.0, 1.0, 0.0)
|
||||
_model.update_state("ground", 9.0, false)
|
||||
_worst_run_stride_error = maxf(_worst_run_stride_error,
|
||||
absf(_model.locomotion_effective_speed_debug() - 9.0))
|
||||
if _motion_frame > 30:
|
||||
_worst_run_stride_error = maxf(_worst_run_stride_error,
|
||||
absf(_model.locomotion_effective_speed_debug() - 9.0))
|
||||
if _motion_frame == RUN_FRAMES - 1:
|
||||
_snap("loco_stop_00_run")
|
||||
else:
|
||||
@@ -73,28 +75,31 @@ func _process(_delta: float) -> bool:
|
||||
_stop_frame += 1
|
||||
_model.set_locomotion(0.0, 0.0, 0.0)
|
||||
_model.update_state("ground", 0.0, false)
|
||||
_worst_stop_stride = maxf(_worst_stop_stride,
|
||||
_model.locomotion_effective_speed_debug())
|
||||
if _stop_frame == 0:
|
||||
_first_stop_stride = _model.locomotion_effective_speed_debug()
|
||||
_settled_stop_stride = _model.locomotion_effective_speed_debug()
|
||||
_model._process(DT)
|
||||
|
||||
if _stop_frame >= 0:
|
||||
match _stop_frame:
|
||||
3:
|
||||
_snap("loco_stop_01_idle_blend")
|
||||
9:
|
||||
12:
|
||||
_snap("loco_stop_02_idle_arrival")
|
||||
20:
|
||||
30:
|
||||
_snap("loco_stop_03_idle")
|
||||
50:
|
||||
_snap("loco_stop_04_settled")
|
||||
if _worst_run_stride_error > 0.02:
|
||||
if _worst_run_stride_error > 0.20:
|
||||
_failures.append("run stride diverged from physical speed")
|
||||
if _worst_stop_stride > 0.02:
|
||||
_failures.append("authored stride did not stop immediately")
|
||||
if _first_stop_stride < 4.0:
|
||||
_failures.append("run pose snapped to idle on the stop frame")
|
||||
if _settled_stop_stride > 0.5:
|
||||
_failures.append("authored stride did not ease into idle")
|
||||
if absf(_model.get_brake_debug()) > 0.001:
|
||||
_failures.append("procedural brake pose is still active")
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
return false
|
||||
|
||||
|
||||
@@ -145,8 +150,8 @@ func _snap(tag: String) -> void:
|
||||
|
||||
func _finish() -> void:
|
||||
if _failures.is_empty() and _stop_frame >= 50:
|
||||
print("LOCO_STOP PASS: run error %.5f, stop stride %.5f, procedural brake %.5f" % [
|
||||
_worst_run_stride_error, _worst_stop_stride,
|
||||
print("LOCO_STOP PASS: run error %.5f, stop ease %.5f -> %.5f, procedural brake %.5f" % [
|
||||
_worst_run_stride_error, _first_stop_stride, _settled_stop_stride,
|
||||
_model.get_brake_debug()])
|
||||
quit(0)
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
extends SceneTree
|
||||
|
||||
## Photograph every character in the picker, one shot each.
|
||||
## Photograph every character in the loadout selector, 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
|
||||
@@ -56,7 +56,7 @@ func _process(_delta: float) -> bool:
|
||||
quit(1)
|
||||
return true
|
||||
_menu.visible = true
|
||||
_menu._show_character()
|
||||
_menu._show_loadouts()
|
||||
_next()
|
||||
return false
|
||||
if (_frames - 150) % SETTLE == 0:
|
||||
@@ -67,11 +67,11 @@ func _process(_delta: float) -> bool:
|
||||
|
||||
func _next() -> void:
|
||||
_index += 1
|
||||
while _index < _menu.character_list.item_count:
|
||||
var id: String = _menu.character_list.get_item_metadata(_index)
|
||||
while _index < _menu.skin_opt.item_count:
|
||||
var id: String = _menu.skin_opt.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)
|
||||
_menu.skin_opt.select(_index)
|
||||
_menu._on_skin_option_selected(_index)
|
||||
if _menu._preview_model != null:
|
||||
_posing = id
|
||||
return
|
||||
@@ -88,8 +88,8 @@ func _shoot() -> void:
|
||||
# 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)))
|
||||
img = img.get_region(Rect2i(int(w * 0.735), int(h * 0.145),
|
||||
int(w * 0.245), int(h * 0.82)))
|
||||
var path := "%s/roster_%s.png" % [_out_dir, id]
|
||||
var save_error := img.save_png(path)
|
||||
if save_error == OK:
|
||||
|
||||
@@ -49,6 +49,18 @@ const VIEWS := [
|
||||
["sidewalk_corner", Vector3(13.0, 10.0, 7.0), Vector3(5.9, 0.2, 14.1), 52.0],
|
||||
["street_sign", Vector3(3.5, 1.8, -18.0), Vector3(8.15, 2.0, -21.85), 46.0],
|
||||
["bikes", Vector3(20.0, 2.0, 8.0), Vector3(32.0, 1.2, 13.0), 55.0],
|
||||
# Grazing-angle regression views for the fullscreen depth-ink pass. A flat
|
||||
# ground plane must not turn into a dark wedge as the camera approaches the
|
||||
# horizon; these three pitches keep that failure visible and reproducible.
|
||||
["ground_grazing", Vector3(0.0, 1.7, 62.0), Vector3(0.0, 1.7, -70.0), 70.0],
|
||||
["ground_shallow", Vector3(0.0, 1.7, 62.0), Vector3(0.0, -5.0, -70.0), 70.0],
|
||||
["ground_normal", Vector3(0.0, 1.7, 62.0), Vector3(0.0, -28.0, -70.0), 70.0],
|
||||
["ground_zoomed", Vector3(0.0, 1.7, 62.0), Vector3(0.0, 1.7, -70.0), 35.0],
|
||||
["ground_wide", Vector3(0.0, 1.7, 62.0), Vector3(0.0, 1.7, -70.0), 95.0],
|
||||
["shopping_band", Vector3(-4.0, 1.7, -30.0), Vector3(80.0, 1.7, -30.0), 70.0],
|
||||
["shopping_band_zoomed", Vector3(-4.0, 1.7, -30.0), Vector3(80.0, 1.7, -30.0), 35.0],
|
||||
["shopping_band_wide", Vector3(-4.0, 1.7, -30.0), Vector3(80.0, 1.7, -30.0), 95.0],
|
||||
["park_ground_grazing", Vector3(34.0, 1.7, -65.0), Vector3(90.0, 1.7, -65.0), 70.0],
|
||||
]
|
||||
|
||||
var _out_dir := "."
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
extends SceneTree
|
||||
|
||||
## Regression audit for the grazing-angle dark band that the ink post-process
|
||||
## used to paint across flat roads and walls. The sampled view reproduces the
|
||||
## original report exactly; both distant road samples must remain close to an
|
||||
## unobstructed road sample nearer the camera.
|
||||
|
||||
const MAP := "res://scenes/maps/sakura_crossing/sakura_crossing.tscn"
|
||||
const FAR_ROAD_UVS := [Vector2(0.46875, 0.52778), Vector2(0.4948, 0.537)]
|
||||
const REFERENCE_ROAD_UV := Vector2(0.5, 0.62)
|
||||
const MIN_LUMINANCE_RATIO := 0.75
|
||||
|
||||
|
||||
func _sample(image: Image, uv: Vector2) -> Color:
|
||||
var x := clampi(roundi(uv.x * image.get_width()), 0, image.get_width() - 1)
|
||||
var y := clampi(roundi(uv.y * image.get_height()), 0, image.get_height() - 1)
|
||||
return image.get_pixel(x, y)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var level := (load(MAP) as PackedScene).instantiate()
|
||||
root.add_child(level)
|
||||
for _i in 120:
|
||||
await process_frame
|
||||
|
||||
var camera := Camera3D.new()
|
||||
camera.fov = 70.0
|
||||
camera.far = 1200.0
|
||||
level.add_child(camera)
|
||||
camera.current = true
|
||||
camera.global_position = Vector3(-4.0, 1.7, -30.0)
|
||||
camera.look_at(Vector3(80.0, 1.7, -30.0), Vector3.UP)
|
||||
for _i in 6:
|
||||
await process_frame
|
||||
|
||||
var image := root.get_texture().get_image()
|
||||
var reference_luma := _sample(image, REFERENCE_ROAD_UV).get_luminance()
|
||||
var minimum_far_luma := 1.0
|
||||
for uv in FAR_ROAD_UVS:
|
||||
minimum_far_luma = minf(minimum_far_luma, _sample(image, uv).get_luminance())
|
||||
var ratio := minimum_far_luma / maxf(reference_luma, 0.001)
|
||||
var failed := ratio < MIN_LUMINANCE_RATIO
|
||||
if failed:
|
||||
push_error("GROUND_BAND_AUDIT dark distant road: far/reference=%.3f (minimum %.3f)" % [
|
||||
ratio, MIN_LUMINANCE_RATIO])
|
||||
else:
|
||||
print("GROUND_BAND_AUDIT PASS far/reference=%.3f" % ratio)
|
||||
|
||||
var args := OS.get_cmdline_user_args()
|
||||
if not args.is_empty():
|
||||
var output := args[0]
|
||||
if not output.is_absolute_path():
|
||||
output = ProjectSettings.globalize_path(output)
|
||||
DirAccess.make_dir_recursive_absolute(output.get_base_dir())
|
||||
image.save_png(output)
|
||||
print("GROUND_BAND_AUDIT saved ", output)
|
||||
|
||||
level.queue_free()
|
||||
for _i in 6:
|
||||
await process_frame
|
||||
quit(1 if failed else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bij2lmy28usm3
|
||||
@@ -281,6 +281,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
client_rep_config.add_property(":synced_is_crouching")
|
||||
client_rep_config.add_property(":synced_is_ads")
|
||||
client_rep_config.add_property(":synced_wall_side")
|
||||
client_rep_config.add_property(":synced_helmet_closed")
|
||||
client_rep_config.add_property(":synced_is_dancing")
|
||||
# Which of the five emotes, so other players see the one that was chosen.
|
||||
client_rep_config.add_property(":synced_dance_index")
|
||||
|
||||
+77
-63
@@ -1,27 +1,27 @@
|
||||
extends SceneTree
|
||||
|
||||
## Regression for the original "slide, then walk, then run" locomotion bug.
|
||||
##
|
||||
## godot --headless --path . -s res://debug/transition_check.gd -- [skin_glb]
|
||||
##
|
||||
## Ground acceleration is the same exponential response as state_ground.gd.
|
||||
## The test proves that the authored BlendSpace receives that physical speed on
|
||||
## the same frame, including the first high-acceleration frame and the stop
|
||||
## frame. It also proves that the removed procedural lean/brake layer stays off.
|
||||
## Regression for natural authored locomotion transitions.
|
||||
## Gameplay velocity remains responsive, but the visual BlendSpace must ease
|
||||
## into acceleration, direction changes and idle instead of teleporting the
|
||||
## skeleton between unrelated poses on one frame.
|
||||
|
||||
const RESPONSE := 60.0
|
||||
const TOP := 11.0
|
||||
const DT := 1.0 / 60.0
|
||||
const RUN_FRAMES := 24
|
||||
const RUN_FRAMES := 48
|
||||
const STOP_FRAMES := 60
|
||||
|
||||
var _frames := 0
|
||||
var _model: SkinnedPlayerModel = null
|
||||
var _model: SkinnedPlayerModel
|
||||
var _speed := 0.0
|
||||
var _first_speed := -1.0
|
||||
var _first_stride := -1.0
|
||||
var _worst_error := 0.0
|
||||
var _stop_stride := -1.0
|
||||
var _manual_tick := false
|
||||
var _first_speed := 0.0
|
||||
var _first_stride := 0.0
|
||||
var _settled_run_stride := 0.0
|
||||
var _first_stop_stride := 0.0
|
||||
var _mid_stop_stride := 0.0
|
||||
var _settled_stop_stride := 0.0
|
||||
var _largest_stride_step := 0.0
|
||||
var _previous_stride := 0.0
|
||||
var _foot_bone := -1
|
||||
var _foot_rotation_start := Quaternion.IDENTITY
|
||||
var _foot_rotation_delta := 0.0
|
||||
@@ -29,8 +29,8 @@ var _foot_rotation_delta := 0.0
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var path: String = args[0] if args.size() > 0 \
|
||||
else "res://assets/characters/skins/miku.glb"
|
||||
var path: String = args[0] if not args.is_empty() \
|
||||
else "res://assets/characters/skins/kiyoko.glb"
|
||||
var scene := Node3D.new()
|
||||
root.add_child(scene)
|
||||
current_scene = scene
|
||||
@@ -43,26 +43,26 @@ func _process(_delta: float) -> bool:
|
||||
_frames += 1
|
||||
if _frames < 8 or not _model.loaded:
|
||||
return false
|
||||
if not _manual_tick:
|
||||
if _frames == 8:
|
||||
_model.set_process(false)
|
||||
_manual_tick = true
|
||||
if _model._spring_mod:
|
||||
_model._spring_mod.lod = 3
|
||||
_model._spring_mod = null
|
||||
_foot_bone = _model._role_bone("foot.L",
|
||||
["foot.L", "LeftFoot", "Left ankle"])
|
||||
|
||||
var sample := _frames - 8
|
||||
if sample < RUN_FRAMES:
|
||||
var response := 1.0 - exp(-RESPONSE * DT)
|
||||
_speed = lerpf(_speed, TOP, response)
|
||||
_speed = lerpf(_speed, TOP, 1.0 - exp(-RESPONSE * DT))
|
||||
else:
|
||||
_speed = 0.0
|
||||
|
||||
# Same order as the live controller: direction first, then physical speed.
|
||||
_model.set_locomotion(0.0, 1.0 if _speed > 0.01 else 0.0, 0.0)
|
||||
_model.update_state("ground", _speed, false)
|
||||
_model._process(DT)
|
||||
|
||||
var stride := _model.locomotion_effective_speed_debug()
|
||||
_worst_error = maxf(_worst_error, absf(stride - _speed))
|
||||
_largest_stride_step = maxf(_largest_stride_step, absf(stride - _previous_stride))
|
||||
_previous_stride = stride
|
||||
|
||||
if _foot_bone >= 0:
|
||||
var foot_rotation := _model.skeleton.get_bone_pose_rotation(_foot_bone)
|
||||
if sample == 1:
|
||||
@@ -70,46 +70,60 @@ func _process(_delta: float) -> bool:
|
||||
elif sample > 1 and sample < RUN_FRAMES:
|
||||
_foot_rotation_delta = maxf(_foot_rotation_delta,
|
||||
_foot_rotation_start.angle_to(foot_rotation))
|
||||
|
||||
if sample == 0:
|
||||
_first_speed = _speed
|
||||
_first_stride = stride
|
||||
if sample == RUN_FRAMES:
|
||||
_stop_stride = stride
|
||||
|
||||
if sample > RUN_FRAMES + 2:
|
||||
print("\n=== velocity-driven locomotion ===")
|
||||
print(" first frame: body %.3f m/s, authored stride %.3f m/s" \
|
||||
% [_first_speed, _first_stride])
|
||||
print(" worst stride/speed error: %.5f m/s" % _worst_error)
|
||||
print(" stop-frame stride: %.5f m/s" % _stop_stride)
|
||||
print(" animated foot rotation delta: %.5f rad" % _foot_rotation_delta)
|
||||
print(" procedural lean %.5f, brake %.5f" \
|
||||
% [_model.get_lean_debug(), _model.get_brake_debug()])
|
||||
|
||||
var failures: Array[String] = []
|
||||
if _first_speed < 6.0:
|
||||
failures.append("test did not reproduce high first-frame acceleration")
|
||||
if absf(_first_stride - _first_speed) > 0.02:
|
||||
failures.append("legs did not receive first-frame physical speed")
|
||||
if _worst_error > 0.02:
|
||||
failures.append("authored stride diverged from physical speed")
|
||||
if _stop_stride > 0.02:
|
||||
failures.append("legs did not reach Idle on the stop frame")
|
||||
if _foot_bone < 0:
|
||||
failures.append("could not resolve a foot bone")
|
||||
elif _foot_rotation_delta < 0.03:
|
||||
failures.append("runtime foot pose stayed static")
|
||||
if absf(_model.get_lean_debug()) > 0.02:
|
||||
failures.append("procedural locomotion lean is still active")
|
||||
if absf(_model.get_brake_debug()) > 0.02:
|
||||
failures.append("procedural stop pose is still active")
|
||||
|
||||
if failures.is_empty():
|
||||
print("TRANSITION_CHECK PASS: same-frame, authored locomotion\n")
|
||||
quit(0)
|
||||
else:
|
||||
for failure in failures:
|
||||
push_error("TRANSITION_CHECK FAIL: " + failure)
|
||||
quit(1)
|
||||
elif sample == RUN_FRAMES - 1:
|
||||
_settled_run_stride = stride
|
||||
elif sample == RUN_FRAMES:
|
||||
_first_stop_stride = stride
|
||||
elif sample == RUN_FRAMES + 12:
|
||||
_mid_stop_stride = stride
|
||||
elif sample == RUN_FRAMES + STOP_FRAMES:
|
||||
_settled_stop_stride = stride
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
_model.update_state("dash", 13.0, false)
|
||||
var dash_crossfade := _model._state_trans.xfade_time
|
||||
_model.update_state("ground", 0.0, false)
|
||||
var ground_crossfade := _model._state_trans.xfade_time
|
||||
print("\n=== NATURAL LOCOMOTION TRANSITIONS ===")
|
||||
print(" first frame body %.3f, visual stride %.3f" % [_first_speed, _first_stride])
|
||||
print(" settled run %.3f, stop %.3f -> %.3f -> %.3f" % [
|
||||
_settled_run_stride, _first_stop_stride, _mid_stop_stride,
|
||||
_settled_stop_stride])
|
||||
print(" largest visual stride step %.3f m/s" % _largest_stride_step)
|
||||
print(" traversal/ground crossfades %.3f / %.3f s" % [
|
||||
dash_crossfade, ground_crossfade])
|
||||
|
||||
var failures: Array[String] = []
|
||||
if _first_speed < 6.0:
|
||||
failures.append("test did not reproduce high gameplay acceleration")
|
||||
if _first_stride <= 0.15 or _first_stride >= _first_speed * 0.65:
|
||||
failures.append("acceleration visual either froze or snapped on frame one")
|
||||
if absf(_settled_run_stride - TOP) > 0.30:
|
||||
failures.append("visual stride did not converge to physical run speed")
|
||||
if _first_stop_stride < _settled_run_stride * 0.65:
|
||||
failures.append("run pose snapped too close to idle on the stop frame")
|
||||
if _mid_stop_stride >= _first_stop_stride or _mid_stop_stride < 0.5:
|
||||
failures.append("run-to-idle recovery is not a progressive ease")
|
||||
if _settled_stop_stride > 0.30:
|
||||
failures.append("run-to-idle recovery did not settle")
|
||||
if _largest_stride_step > 2.5:
|
||||
failures.append("a rendered frame contains an excessive gait jump")
|
||||
if dash_crossfade < 0.19 or ground_crossfade < 0.28:
|
||||
failures.append("full-body state crossfades are still too abrupt")
|
||||
if _foot_bone < 0 or _foot_rotation_delta < 0.03:
|
||||
failures.append("authored foot animation did not move")
|
||||
if failures.is_empty():
|
||||
print("TRANSITION_CHECK PASS: responsive gameplay, eased animation\n")
|
||||
quit(0)
|
||||
else:
|
||||
for failure in failures:
|
||||
push_error("TRANSITION_CHECK FAIL: " + failure)
|
||||
quit(1)
|
||||
|
||||
@@ -11,7 +11,8 @@ extends SceneTree
|
||||
## This is the failure mode the test exists to catch: the old lateral cycle was
|
||||
## technically "matched" at 5 m/s but exploded to 6.1x playback in gameplay.
|
||||
const SPEED := 11.0
|
||||
const SETTLE_FRAMES := 18
|
||||
const DT := 1.0 / 60.0
|
||||
const SETTLE_FRAMES := 45
|
||||
const SAMPLE_FRAMES := 24
|
||||
const CASES := [
|
||||
["forward", 0.0, 1.0, "Sprint"],
|
||||
@@ -48,6 +49,10 @@ func _run() -> void:
|
||||
_expect(false, "the authored character loads")
|
||||
_done()
|
||||
return
|
||||
if model._spring_mod:
|
||||
model._spring_mod.lod = 3
|
||||
model._spring_mod = null
|
||||
model.set_process(false)
|
||||
|
||||
var foot := model._role_bone("foot.L", ["foot.L", "LeftFoot", "Left ankle"])
|
||||
_expect(foot >= 0, "a foot bone resolves")
|
||||
@@ -57,13 +62,14 @@ func _run() -> void:
|
||||
for _frame in SETTLE_FRAMES:
|
||||
model.set_locomotion(direction.x, direction.y, 0.0)
|
||||
model.update_state("ground", SPEED, false)
|
||||
model._process(DT)
|
||||
await process_frame
|
||||
|
||||
var blend := model.locomotion_blend_debug()
|
||||
_expect(blend.length() > 0.01 and blend.normalized().dot(direction) > 0.999,
|
||||
"%s drives the authored BlendSpace in the physical direction"
|
||||
% String(test_case[0]))
|
||||
_expect(absf(model.locomotion_effective_speed_debug() - SPEED) < 0.02,
|
||||
_expect(absf(model.locomotion_effective_speed_debug() - SPEED) < 0.25,
|
||||
"%s authored stride matches %.1f m/s"
|
||||
% [String(test_case[0]), SPEED])
|
||||
var expected_clip := String(test_case[3])
|
||||
@@ -77,6 +83,7 @@ func _run() -> void:
|
||||
for _frame in SAMPLE_FRAMES:
|
||||
model.set_locomotion(direction.x, direction.y, 0.0)
|
||||
model.update_state("ground", SPEED, false)
|
||||
model._process(DT)
|
||||
await process_frame
|
||||
greatest = maxf(greatest, start.angle_to(
|
||||
model.skeleton.get_bone_pose_rotation(foot)))
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
extends SceneTree
|
||||
|
||||
## Regression gate for authored wall running and physical grapple posing.
|
||||
## Regression gate for jet-propelled wall traversal and physical grapple posing.
|
||||
##
|
||||
## Wall running must select side-specific authored performances, never a ground
|
||||
## run under another name. Grapple must keep its authored airborne base while
|
||||
## Wall traversal must select the authored side-specific pilot performances,
|
||||
## keep their source timing independent of gameplay speed, and face along the
|
||||
## live wall tangent. Jets still own propulsion; the animation owns silhouette.
|
||||
## Grapple must keep its authored airborne base while
|
||||
## the live arm constraint lands the palm on the cable ray and body alignment
|
||||
## responds to pendulum energy.
|
||||
|
||||
const SETTLE_FRAMES := 90
|
||||
const SAMPLE_FRAMES := 30
|
||||
const WALL_RUN_SPEED := 13.0
|
||||
const WALL_RUN_REFERENCE_SPEED := 8.25
|
||||
const WALL_GLIDE_SPEED := 13.0
|
||||
|
||||
var _failures := 0
|
||||
|
||||
@@ -48,27 +49,27 @@ func _run() -> void:
|
||||
"hips and both hands resolve")
|
||||
|
||||
model.set_wall_side(-1.0)
|
||||
model.set_wall_run_motion(Vector3(13.0, 0.0, 0.0))
|
||||
await _settle(model, "wall_run", WALL_RUN_SPEED)
|
||||
model.set_wall_glide_motion(Vector3(13.0, 0.0, 0.0))
|
||||
await _settle(model, "wall_run", WALL_GLIDE_SPEED)
|
||||
_expect(model.current_clip_debug() == "WallRunLeft",
|
||||
"left wall selects the authored WallRunLeft performance")
|
||||
"left wall traversal selects its authored pilot performance")
|
||||
var wall_scale := float(model._anim_tree.get("parameters/loco_scale/scale"))
|
||||
_expect(absf(wall_scale - WALL_RUN_SPEED / WALL_RUN_REFERENCE_SPEED) < 0.02,
|
||||
"wall-run cadence tracks %.1f m/s (%.3fx)"
|
||||
% [WALL_RUN_SPEED, wall_scale])
|
||||
_expect(absf(wall_scale - 1.0) < 0.02,
|
||||
"wall traversal keeps the authored source timing (%.3fx)"
|
||||
% wall_scale)
|
||||
var wall_foot_motion := await _sample_foot_motion(
|
||||
model, foot, "wall_run", WALL_RUN_SPEED)
|
||||
_expect(wall_foot_motion > 0.30,
|
||||
"wall-run has a full wall-stride leg cycle (%.3f rad)"
|
||||
model, foot, "wall_run", WALL_GLIDE_SPEED)
|
||||
_expect(wall_foot_motion > 0.12,
|
||||
"wall traversal retains its authored propulsion cycle (%.3f rad motion)"
|
||||
% wall_foot_motion)
|
||||
var wall_forward := model.wall_run_forward_debug()
|
||||
var wall_forward := model.wall_glide_forward_debug()
|
||||
_expect(wall_forward.dot(Vector3.RIGHT) > 0.97,
|
||||
"wall-run model faces along wall-tangent velocity (dot %.3f)"
|
||||
"wall-glide model faces along wall-tangent velocity (dot %.3f)"
|
||||
% wall_forward.dot(Vector3.RIGHT))
|
||||
model.set_wall_side(1.0)
|
||||
await _settle(model, "wall_run", WALL_RUN_SPEED)
|
||||
await _settle(model, "wall_run", WALL_GLIDE_SPEED)
|
||||
_expect(model.current_clip_debug() == "WallRunRight",
|
||||
"right wall selects the authored WallRunRight performance")
|
||||
"right wall traversal selects its authored pilot performance")
|
||||
_expect(not model.clip_names_debug().has("WallRun"),
|
||||
"the relabelled ground-run WallRun clip is absent")
|
||||
|
||||
@@ -98,7 +99,8 @@ func _run() -> void:
|
||||
model.set_locomotion(0.0, -1.0, 0.0)
|
||||
await _settle(model, "ground", 8.25)
|
||||
var spine_names: Array = model._rig_info.get("roles", {}).get("spine", [])
|
||||
var first_spine := model.skeleton.find_bone(String(spine_names[0])) \
|
||||
var first_spine := RigRoles.find_imported_bone(
|
||||
model.skeleton, String(spine_names[0])) \
|
||||
if not spine_names.is_empty() else -1
|
||||
_expect(first_spine >= 0, "the first spine bone resolves")
|
||||
if first_spine >= 0:
|
||||
@@ -113,8 +115,12 @@ func _run() -> void:
|
||||
var hips_delta := hips_pose * hips_rest.inverse()
|
||||
var spine_delta := spine_pose * spine_rest.inverse()
|
||||
var abdomen_twist := hips_delta.angle_to(spine_delta)
|
||||
_expect(abdomen_twist < 1.45,
|
||||
"backward capture keeps hips and abdomen coherent (%.3f rad)"
|
||||
# The Titanfall combat-backpedal deliberately keeps the weapon-facing
|
||||
# chest counter-rotated over the travelling hips. The old broken capture
|
||||
# was a full 180-degree lower-body reversal; preserve the authored turn
|
||||
# while still rejecting that corkscrew.
|
||||
_expect(abdomen_twist < 2.25,
|
||||
"backward capture keeps the authored torso counter-turn (%.3f rad)"
|
||||
% abdomen_twist)
|
||||
|
||||
var anchor := model.global_position + Vector3(3.0, 7.0, -6.0)
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@ extends SceneTree
|
||||
##
|
||||
## godot --path . -s res://debug/ui_capture.gd -- <output_dir>
|
||||
##
|
||||
## Writes shot_menu.png, shot_pause.png, shot_character.png and
|
||||
## Writes shot_menu.png, shot_pause.png, shot_loadouts.png and
|
||||
## shot_settings.png.
|
||||
|
||||
var _frames := 0
|
||||
@@ -41,10 +41,10 @@ func _process(_delta: float) -> bool:
|
||||
_open("_show_main_menu")
|
||||
215:
|
||||
_shot("pause")
|
||||
_open("_show_character")
|
||||
_open("_show_loadouts")
|
||||
320:
|
||||
# Long enough for the model to load and the springs to settle.
|
||||
_shot("character")
|
||||
_shot("loadouts")
|
||||
_open("_show_settings")
|
||||
340:
|
||||
_shot("settings")
|
||||
|
||||
Reference in New Issue
Block a user