ani fixed

This commit is contained in:
Nicholas Butzke
2026-08-10 12:17:59 -04:00
parent 9358746582
commit 833e936bcd
10 changed files with 554 additions and 23 deletions
+1
View File
@@ -24,6 +24,7 @@ const CANONICAL := [
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
"DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L",
"DEF-foot.R", "DEF-foot.L",
]
+118 -9
View File
@@ -100,10 +100,16 @@ const BLEND_TIME := 0.36
## visible.
const BLEND_TIMES := {
"Dash": 0.20, "Jump": 0.24, "Hit": 0.14, "Land": 0.28,
"Slide": 0.30, "Death": 0.34, "Throw": 0.20, "PistolReload": 0.30,
# SlideStart has a distinct authored crouch pose; blending into it from
# airborne/standing can send the lower body through a ground-plane flip.
"SlideStart": 0.0, "Slide": 0.30, "Death": 0.34,
"Throw": 0.20, "PistolReload": 0.30,
"Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40,
"CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.32,
"WallRunLeft": 0.26, "WallRunRight": 0.26,
# The two authored wall performances start in distinct side poses. A full
# body crossfade from Fall/ground interpolates the hips and thighs through
# an upside-down midpoint, so hand off directly to the authored first pose.
"WallRunLeft": 0.0, "WallRunRight": 0.0,
"WallCling": 0.32, "Grapple": 0.32,
}
const GROUND_STATE_BLEND := 0.30
@@ -125,7 +131,10 @@ var loaded: bool = false
## manufactures poses.
var _motion_root: Node3D
var _wall_glide_velocity_world := Vector3.ZERO
var _wall_surface_normal_world := Vector3.ZERO
var _wall_surface_point_world := Vector3.ZERO
var _motion_yaw := 0.0
var _motion_was_wall_run := false
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
var _current_clip: String = ""
@@ -221,7 +230,10 @@ func load_model(path: String) -> void:
animation_player = null
_motion_root = null
_wall_glide_velocity_world = Vector3.ZERO
_wall_surface_normal_world = Vector3.ZERO
_wall_surface_point_world = Vector3.ZERO
_motion_yaw = 0.0
_motion_was_wall_run = false
_loco_blend_target = Vector2.ZERO
_loco_blend_visual = Vector2.ZERO
_loco_scale_target = 1.0
@@ -233,6 +245,7 @@ func load_model(path: String) -> void:
_current_clip = ""
_has_titanfall_motion_reference = false
_current_state_node = ""
_slide_start_remaining = 0.0
_weapon_attachment = null
_grapple_attachment = null
_grapple_shoulder_attachment = null
@@ -564,8 +577,12 @@ func _ensure_meshes_bound(scene: Node) -> void:
## loop modes (glTF has no loop flag, so we set it here).
func _index_animations() -> void:
var available := animation_player.get_animation_list()
# ReviewWallHangFront is unique to the ignored local Titanfall motion lab.
# A canonical WallRunLeft/Right pair also exists in shipping animation packs
# and must not opt those rigs into the Titanfall-specific side convention.
_has_titanfall_motion_reference = \
_find_clip(available, "ReviewWallHangFront") != ""
_normalize_rotation_track_signs()
for canonical in CLIP_FALLBACKS:
for candidate in CLIP_FALLBACKS[canonical]:
var match_name := _find_clip(available, candidate)
@@ -579,6 +596,34 @@ func _index_animations() -> void:
anim.loop_mode = Animation.LOOP_LINEAR
## Quaternions q and -q encode the same pose, but interpolation does not know
## that unless adjacent keys use a consistent sign. A few imported traversal
## tracks contain those sign changes, so Godot interpolates the long arc and
## briefly turns the waist/legs upside down on wall-run entry. Normalize every
## rotation track once after GLB import so the authored pose is preserved while
## interpolation always takes the shortest arc between keys.
func _normalize_rotation_track_signs() -> void:
for animation_name in animation_player.get_animation_list():
var animation := animation_player.get_animation(animation_name)
if animation == null:
continue
for track_index in animation.get_track_count():
if animation.track_get_type(track_index) != Animation.TYPE_ROTATION_3D:
continue
var previous := Quaternion.IDENTITY
var has_previous := false
for key_index in animation.track_get_key_count(track_index):
var value = animation.track_get_key_value(track_index, key_index)
if not value is Quaternion:
continue
var current: Quaternion = value.normalized()
if has_previous and previous.dot(current) < 0.0:
current = -current
animation.track_set_key_value(track_index, key_index, current)
previous = current
has_previous = true
## Runtime blend tree:
##
## authored cycles -> direction/speed BlendSpaces \
@@ -733,6 +778,7 @@ func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
var _prev_state: String = ""
var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
var _slide_start_remaining: float = 0.0
var _dancing: bool = false
## Which authored entry in DanceRoutines.ROUTINES is playing.
var _dance_index: int = 0
@@ -801,6 +847,8 @@ func add_gun_recoil(strength: float = 1.0) -> void:
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
if not loaded or not animation_player:
return
var previous_state := _prev_state
var exiting_wall_run := previous_state == "wall_run" and state != "wall_run"
# One-shots (Land, Hit) own playback briefly.
if _oneshot_lock > 0.0:
@@ -816,6 +864,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
_prev_state = state
return
_prev_state = state
if state != "slide":
_slide_start_remaining = 0.0
var clip := "Idle"
match state:
@@ -829,12 +879,22 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
# Rising = jump, falling = the fall loop.
clip = "Jump" if _vertical_speed() > 0.5 else "Fall"
"slide":
clip = "Slide"
if previous_state != "slide":
_slide_start_remaining = _animation_length_if_distinct(
"SlideStart", "Slide") / _slide_playback_scale(speed)
if _slide_start_remaining > 0.0:
clip = "SlideStart"
else:
clip = "Slide"
"wall_run":
# The jets still own propulsion, but the authored pilot pack supplies a
# distinct compact wall-performance for each side. This is body motion,
# not gameplay root motion or procedural feet planted against the wall.
if _has_titanfall_motion_reference:
# The imported pair is mirrored relative to gameplay's wall-side
# convention: the clip named Right is the left-wall performance and
# vice versa. Keep that conversion here rather than swapping physics
# wall_side values, which would break camera tilt and networking.
clip = "WallRunRight" if _target_wall < 0.0 else "WallRunLeft"
else:
clip = "Grapple"
@@ -850,7 +910,10 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
clip = "Death"
if clip != "":
_play_clip(clip)
# A wall jump hands the full body from a side performance to Jump/Fall.
# Interpolating those two poses can put the thighs through the horizontal
# midpoint, which reads as a delayed leg flip on the jump frame.
_play_clip(clip, false, 0.0 if exiting_wall_run else -1.0)
if _pose_mod:
_pose_mod.state = state
@@ -862,8 +925,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
# gameplay speed; never synthesize a different pose.
if _anim_tree and clip != "":
var scale := 1.0
if clip == "Slide":
scale = clampf(speed / run_anim_reference_speed, 0.75, 1.8)
if clip in ["SlideStart", "Slide"]:
scale = _slide_playback_scale(speed)
elif clip == "Grapple" or clip in ["WallRunLeft", "WallRunRight"]:
# Grapple is held, and the wall clips are compact propulsion loops.
# Neither should become a frantic treadmill at high traversal speed.
@@ -888,6 +951,16 @@ func set_wall_side(side: float) -> void:
_target_wall = clampf(side, -1.0, 1.0)
## Retain the live wall plane as traversal telemetry. The authored animation
## remains untouched; this is available to presentation/debug consumers only.
## `normal_world` points from the wall toward the player.
func set_wall_surface(normal_world: Vector3,
contact_point_world: Vector3 = Vector3.ZERO) -> void:
_wall_surface_normal_world = normal_world.normalized() \
if normal_world.length_squared() > 0.0001 else Vector3.ZERO
_wall_surface_point_world = contact_point_world
## Gameplay velocity aligns the held glide silhouette with the live wall
## tangent. The mechanic still reports `wall_run` internally for save/network
## compatibility, but the character presentation is entirely propulsion-based.
@@ -998,6 +1071,8 @@ var _lod_timer: float = 0.0
func _process(delta: float) -> void:
_update_cloth_lod(delta)
_update_motion_orientation(delta)
if _slide_start_remaining > 0.0:
_slide_start_remaining = maxf(_slide_start_remaining - delta, 0.0)
_update_mecha_nozzles()
_update_animation_smoothing(delta)
if not _pose_mod:
@@ -1068,6 +1143,17 @@ func _process(delta: float) -> void:
func _update_motion_orientation(delta: float) -> void:
if not is_instance_valid(_motion_root):
return
var wall_run_active := _pose_mod != null and _pose_mod.state == "wall_run"
if not wall_run_active:
# This root is only a presentation frame for lateral wall-run clips.
# Blending it back to zero makes the whole character visibly spin on the
# floor when a wall run ends into a slide/ground state.
if _motion_was_wall_run:
_motion_yaw = 0.0
_motion_root.rotation.y = 0.0
_motion_was_wall_run = false
return
_motion_was_wall_run = true
var wanted_yaw := 0.0
if _pose_mod and _pose_mod.state == "wall_run":
var local_velocity := global_transform.basis.inverse() \
@@ -1083,6 +1169,24 @@ func _update_motion_orientation(delta: float) -> void:
_motion_root.rotation.y = _motion_yaw
func _animation_length_if_distinct(start: String, loop: String) -> float:
if not _resolved_clips.has(start) or not _resolved_clips.has(loop):
return 0.0
var start_name: String = _resolved_clips[start]
var loop_name: String = _resolved_clips[loop]
if start_name == loop_name:
return 0.0
var clip := animation_player.get_animation(start_name)
return clip.length if clip else 0.0
func _slide_playback_scale(speed: float) -> float:
# SlideStart is part of the same momentum gesture as Slide. It must advance
# with the entry velocity too, otherwise a fast slide spends a full authored
# second in a slow crouch pose before the loop catches up.
return clampf(speed / maxf(run_anim_reference_speed, 0.01), 0.75, 1.8)
## Pick the cloth solver's detail level from how far the camera is.
##
## Re-checked a few times a second rather than every frame: the answer changes
@@ -1142,7 +1246,10 @@ func _play_ground_locomotion(speed: float, crouched: bool) -> void:
return
var state_node := "CrouchLocomotion" if crouched else "Locomotion"
if _current_state_node != state_node:
_state_trans.xfade_time = GROUND_STATE_BLEND
var exiting_wall_run := _current_state_node in [
"WallRunLeft", "WallRunRight"]
_state_trans.xfade_time = 0.0 if exiting_wall_run \
else GROUND_STATE_BLEND
_anim_tree.set("parameters/state/transition_request", state_node)
_current_state_node = state_node
@@ -1193,13 +1300,15 @@ func _play_ground_locomotion(speed: float, crouched: bool) -> void:
_current_clip = _resolved_clips.get(canonical, "")
func _play_clip(canonical: String, restart: bool = false) -> void:
func _play_clip(canonical: String, restart: bool = false,
blend_override: float = -1.0) -> void:
if not _anim_tree or not _resolved_clips.has(canonical):
return
var clip_name: String = _resolved_clips[canonical]
if not restart and _current_state_node == clip_name:
return
_state_trans.xfade_time = BLEND_TIMES.get(canonical, BLEND_TIME)
_state_trans.xfade_time = blend_override \
if blend_override >= 0.0 else BLEND_TIMES.get(canonical, BLEND_TIME)
_anim_tree.set("parameters/state/transition_request", clip_name)
_current_state_node = clip_name
_current_clip = clip_name
+2
View File
@@ -281,6 +281,8 @@ 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_wall_normal")
client_rep_config.add_property(":synced_wall_contact_point")
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.
+152
View File
@@ -0,0 +1,152 @@
extends SceneTree
## Visual regression capture for the local Titanfall motion lab rigs.
##
## godot --path . --windowed --resolution 800x800 \
## --script res://debug/titanfall_motion_capture.gd -- <output-dir> [model.glb]
const DEFAULT_MODEL := \
"res://assets/characters/local_reference/titanfall_motion/mannequin.glb"
const SPEED := 13.0
var _out_dir := "tmp_titanfall_motion_capture"
var _model_path := DEFAULT_MODEL
var _model: SkinnedPlayerModel
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if not args.is_empty():
_out_dir = args[0]
if args.size() > 1:
_model_path = args[1]
if not _out_dir.is_absolute_path():
_out_dir = ProjectSettings.globalize_path(_out_dir)
DirAccess.make_dir_recursive_absolute(_out_dir)
call_deferred("_run")
func _run() -> void:
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
var environment_node := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color(0.08, 0.09, 0.12)
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color(0.78, 0.82, 0.95)
environment.ambient_light_energy = 1.3
environment.tonemap_mode = Environment.TONE_MAPPER_FILMIC
environment_node.environment = environment
scene.add_child(environment_node)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-35.0, 35.0, 0.0)
sun.light_energy = 2.2
scene.add_child(sun)
var camera := Camera3D.new()
scene.add_child(camera)
camera.current = true
camera.global_position = Vector3(0.0, 1.15, 3.0)
camera.look_at(Vector3(0.0, 0.9, 0.0), Vector3.UP)
var body := CharacterBody3D.new()
scene.add_child(body)
_model = SkinnedPlayerModel.new()
_model.model_path = _model_path
_model.first_person_mode = false
_model.shadows_only = false
body.add_child(_model)
for _frame in 60:
await process_frame
if not _model.loaded:
printerr("titanfall_motion_capture: model failed to load")
quit(1)
return
await _drive("ground", 0.0, 90)
_snap("idle")
await _wall(-1.0, Vector3.RIGHT)
_snap("wall_left")
await _drive("ground", 0.0, 90)
await _wall(1.0, Vector3.LEFT)
_snap("wall_right")
_model.set_wall_side(0.0)
_model.set_wall_surface(Vector3.ZERO, Vector3.ZERO)
_model.set_wall_glide_motion(Vector3.ZERO)
await _drive("ground", 0.0, 1)
_snap("post_right_01")
await _drive("ground", 0.0, 19)
_snap("post_right_20")
await _drive("ground", 0.0, 70)
_snap("post_right_idle")
quit()
func _wall(side: float, normal: Vector3) -> void:
_model.set_wall_side(side)
_model.set_wall_surface(normal, Vector3.ZERO)
_model.set_wall_glide_motion(Vector3(0.0, 0.0, -SPEED))
await _drive("wall_run", SPEED, 90)
func _drive(state: String, speed: float, frames: int) -> void:
for _frame in frames:
_model.update_state(state, speed, false)
await process_frame
func _snap(name: String) -> void:
var image := root.get_viewport().get_texture().get_image()
var path := _out_dir.path_join(name + ".png")
image.save_png(path)
print("titanfall_motion_capture: saved %s metrics=%s" % [
path, _pose_metrics()])
func _pose_metrics() -> Dictionary:
var roles: Dictionary = _model._rig_info.get("roles", {})
var chain := [_model._role_bone("hips", ["hips", "Hips", "pelvis"])]
var neck_name := String(roles.get("neck", ""))
var head_name := String(roles.get("head", ""))
for role in roles.get("spine", []):
var name := String(role)
if name == neck_name or name == head_name:
continue
chain.append(RigRoles.find_imported_bone(_model.skeleton, name))
var max_torso_joint_angle := 0.0
var max_torso_joint := ""
for index in range(1, chain.size()):
if chain[index - 1] < 0 or chain[index] < 0:
continue
var parent_pose := _model.skeleton.get_bone_global_pose(chain[index - 1])
var child_pose := _model.skeleton.get_bone_global_pose(chain[index])
var parent_rest := _model.skeleton.get_bone_global_rest(chain[index - 1])
var child_rest := _model.skeleton.get_bone_global_rest(chain[index])
var posed_relative := parent_pose.basis.inverse() * child_pose.basis
var rest_relative := parent_rest.basis.inverse() * child_rest.basis
var joint_angle := posed_relative.get_rotation_quaternion().angle_to(
rest_relative.get_rotation_quaternion())
if joint_angle > max_torso_joint_angle:
max_torso_joint_angle = joint_angle
max_torso_joint = _model.skeleton.get_bone_name(chain[index])
var max_leg_horizontal_ratio := 0.0
for side in ["L", "R"]:
var thigh := _model._role_bone("thigh." + side,
["thigh." + side, "Left thigh" if side == "L" else "Right thigh"])
var shin := _model._role_bone("shin." + side,
["shin." + side, "Left shin" if side == "L" else "Right shin"])
if thigh < 0 or shin < 0:
continue
var segment := _model.skeleton.get_bone_global_pose(shin).origin \
- _model.skeleton.get_bone_global_pose(thigh).origin
segment = _model.skeleton.global_transform.basis * segment
if segment.length() > 0.0001:
max_leg_horizontal_ratio = maxf(max_leg_horizontal_ratio,
Vector2(segment.x, segment.z).length() / segment.length())
return {
"torso_joint_angle": max_torso_joint_angle,
"torso_joint": max_torso_joint,
"leg_horizontal_ratio": max_leg_horizontal_ratio,
}
+1
View File
@@ -0,0 +1 @@
uid://c6lj5365imsrn
+263 -14
View File
@@ -26,7 +26,7 @@ func _run() -> void:
var path: String = args[0] if not args.is_empty() \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
var scene := CharacterBody3D.new()
root.add_child(scene)
current_scene = scene
var model := SkinnedPlayerModel.new()
@@ -39,40 +39,165 @@ func _run() -> void:
_expect(false, "the authored character loads")
_done()
return
var foot := model._role_bone("foot.L", ["foot.L", "LeftFoot", "Left ankle"])
var hips := model._role_bone("hips", ["hips", "Hips", "pelvis"])
var left_hand := model._role_bone("hand.L", ["hand.L", "LeftHand", "hand_l"])
var right_hand := model._role_bone("hand.R", ["hand.R", "RightHand", "hand_r"])
var thigh_l := model._role_bone("thigh.L", ["thigh.L", "Left thigh"])
var thigh_r := model._role_bone("thigh.R", ["thigh.R", "Right thigh"])
var shin_l := model._role_bone("shin.L", ["shin.L", "Left shin"])
var shin_r := model._role_bone("shin.R", ["shin.R", "Right shin"])
var spine_roles: Array = model._rig_info.get("roles", {}).get("spine", [])
var torso_chain := _torso_chain_bones(model, spine_roles)
var lower_spine := RigRoles.find_imported_bone(
model.skeleton, String(spine_roles[0])) \
if not spine_roles.is_empty() else model.skeleton.find_bone(
"DEF-spine.001")
_expect(foot >= 0, "a foot bone resolves")
_expect(hips >= 0 and left_hand >= 0 and right_hand >= 0,
"hips and both hands resolve")
if model._has_titanfall_motion_reference and thigh_l >= 0 \
and thigh_r >= 0 and shin_l >= 0 and shin_r >= 0:
if lower_spine >= 0:
var lower_spine_rest_rise := _world_rest_rise(
model, lower_spine, hips)
for _frame in SETTLE_FRAMES:
model.update_state("idle", 0.0, false)
await process_frame
var idle_spine_rise := _world_pose_rise(model, lower_spine, hips)
_expect(idle_spine_rise > maxf(lower_spine_rest_rise * 0.65, 0.02),
"idle keeps the lower spine above the pelvis (%.3f m)"
% idle_spine_rise)
var idle_horizontal_ratio_max := 0.0
for sample in 120:
idle_horizontal_ratio_max = maxf(idle_horizontal_ratio_max,
_max_leg_horizontal_ratio(model, thigh_l, thigh_r, shin_l, shin_r))
model.update_state("idle", 0.0, false)
await process_frame
_expect(idle_horizontal_ratio_max < 0.55,
"idle keeps both thigh-to-knee segments mostly vertical "
+ "(%.3f horizontal ratio)" % idle_horizontal_ratio_max)
if model._has_titanfall_motion_reference and thigh_l >= 0 \
and thigh_r >= 0 and shin_l >= 0 and shin_r >= 0:
for side in [-1.0, 1.0]:
var entry_leg_rise := await _wall_entry_leg_rise(
model, side, thigh_l, thigh_r, shin_l, shin_r)
_expect(entry_leg_rise < 0.05,
"wall-run entry keeps both legs below the pelvis on side %+.0f "
% side)
model.set_wall_side(-1.0)
model.set_wall_surface(Vector3.RIGHT, Vector3(-0.4, 0.0, 0.0))
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 traversal selects its authored pilot performance")
var expected_left_wall_clip := "WallRunRight" \
if model._has_titanfall_motion_reference else "Grapple"
_expect(model.current_clip_debug() == expected_left_wall_clip,
"left wall traversal selects its configured authored performance")
if model._has_titanfall_motion_reference:
var left_torso_joint := _max_torso_joint_delta(model, torso_chain)
_expect(left_torso_joint < 1.35,
"left wallrun keeps a continuous authored torso (%.3f rad max joint)"
% left_torso_joint)
var wall_scale := float(model._anim_tree.get("parameters/loco_scale/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_GLIDE_SPEED)
_expect(wall_foot_motion > 0.12,
"wall traversal retains its authored propulsion cycle (%.3f rad motion)"
% wall_foot_motion)
if model._has_titanfall_motion_reference:
_expect(wall_foot_motion > 0.12,
"wall traversal retains its authored propulsion cycle "
+ "(%.3f rad motion)" % wall_foot_motion)
else:
_expect(wall_foot_motion < 0.08,
"fallback wall glide holds its authored stabilization pose "
+ "(%.3f rad motion)" % wall_foot_motion)
var wall_forward := model.wall_glide_forward_debug()
_expect(wall_forward.dot(Vector3.RIGHT) > 0.97,
"wall-glide model faces along wall-tangent velocity (dot %.3f)"
% wall_forward.dot(Vector3.RIGHT))
model.set_wall_side(1.0)
model.set_wall_surface(Vector3.LEFT, Vector3(0.4, 0.0, 0.0))
await _settle(model, "wall_run", WALL_GLIDE_SPEED)
_expect(model.current_clip_debug() == "WallRunRight",
"right wall traversal selects its authored pilot performance")
var expected_right_wall_clip := "WallRunLeft" \
if model._has_titanfall_motion_reference else "Grapple"
_expect(model.current_clip_debug() == expected_right_wall_clip,
"right wall traversal selects its configured authored performance")
if model._has_titanfall_motion_reference:
var right_torso_joint := _max_torso_joint_delta(model, torso_chain)
_expect(right_torso_joint < 1.35,
"right wallrun keeps a continuous authored torso (%.3f rad max joint)"
% right_torso_joint)
if model._has_titanfall_motion_reference and thigh_l >= 0 \
and thigh_r >= 0 and shin_l >= 0 and shin_r >= 0 \
and lower_spine >= 0:
var right_wall_spine_rise := _world_pose_rise(
model, lower_spine, hips)
var lower_spine_rest_rise := _world_rest_rise(
model, lower_spine, hips)
_expect(right_wall_spine_rise > maxf(
lower_spine_rest_rise * 0.65, 0.02),
"right wallrun keeps the lower spine above the pelvis (%.3f m)"
% right_wall_spine_rise)
var exit_leg_rise := await _wall_exit_leg_rise(
model, thigh_l, thigh_r, shin_l, shin_r)
_expect(exit_leg_rise < 0.35,
"right wallrun ground exit avoids an exaggerated knee flip")
var jump_exit_leg_rise := await _wall_jump_exit_leg_rise(
model, thigh_l, thigh_r, shin_l, shin_r)
_expect(jump_exit_leg_rise < 0.05,
"right wallrun jump hands off without a leg flip")
_expect(not model.clip_names_debug().has("WallRun"),
"the relabelled ground-run WallRun clip is absent")
# The wall-glide orientation is a temporary presentation frame. It must not
# ease back around the floor after traversal ends, and a character with an
# authored slide entry must use that entry pose before the loop.
model.update_state("ground", WALL_GLIDE_SPEED, false)
await process_frame
_expect(absf(model._motion_root.rotation.y) < 0.001,
"wall-glide presentation yaw resets on exit")
if model._has_titanfall_motion_reference:
for _frame in 20:
model.update_state("idle", 0.0, false)
await process_frame
var recovered_torso_joint := _max_torso_joint_delta(model, torso_chain)
_expect(recovered_torso_joint < 0.70,
"idle after right wallrun has no residual waist rotation "
+ "(%.3f rad max joint)" % recovered_torso_joint)
model.update_state("slide", WALL_GLIDE_SPEED, true)
var slide_start_clip := String(model._resolved_clips.get("SlideStart", ""))
if slide_start_clip != "" and slide_start_clip != \
model._resolved_clips.get("Slide", ""):
_expect(model.current_clip_debug() == "SlideStart",
"slide enters through the authored SlideStart pose")
if model._has_titanfall_motion_reference and thigh_l >= 0 \
and thigh_r >= 0 and shin_l >= 0 and shin_r >= 0:
var slide_entry_rise := -INF
for _frame in 12:
model.update_state("slide", WALL_GLIDE_SPEED, true)
await process_frame
var slide_leg_l := _world_bone_segment(model, thigh_l, shin_l)
var slide_leg_r := _world_bone_segment(model, thigh_r, shin_r)
slide_entry_rise = maxf(slide_entry_rise,
maxf(slide_leg_l.y, slide_leg_r.y))
_expect(slide_entry_rise < 0.05,
"slide entry keeps both legs below the pelvis")
model.update_state("ground", 0.0, false)
await process_frame
model.update_state("slide", 4.0, true)
var slow_slide_scale := float(model._anim_tree.get(
"parameters/loco_scale/scale"))
model.update_state("ground", 0.0, false)
await process_frame
model.update_state("slide", WALL_GLIDE_SPEED, true)
var fast_slide_scale := float(model._anim_tree.get(
"parameters/loco_scale/scale"))
_expect(fast_slide_scale > slow_slide_scale + 0.25,
"slide entry playback follows momentum (%.2fx -> %.2fx)"
% [slow_slide_scale, fast_slide_scale])
# The imported performance must own the unarmed silhouette. A target rig's
# crossed/T-pose rest arms used to survive every clip because only relative
# bone rotations were copied.
@@ -83,13 +208,16 @@ func _run() -> void:
"shoulder.L", ["shoulder.L", "LeftShoulder", "clavicle_l"])
var right_shoulder := model._role_bone(
"shoulder.R", ["shoulder.R", "RightShoulder", "clavicle_r"])
var skeleton_basis := model.skeleton.global_transform.basis
var idle_drop := minf(
model.skeleton.get_bone_global_pose(left_shoulder).origin.y
- model.skeleton.get_bone_global_pose(left_hand).origin.y,
model.skeleton.get_bone_global_pose(right_shoulder).origin.y
- model.skeleton.get_bone_global_pose(right_hand).origin.y,
(skeleton_basis * (model.skeleton.get_bone_global_pose(
left_shoulder).origin - model.skeleton.get_bone_global_pose(
left_hand).origin)).y,
(skeleton_basis * (model.skeleton.get_bone_global_pose(
right_shoulder).origin - model.skeleton.get_bone_global_pose(
right_hand).origin)).y,
)
_expect(idle_drop > 0.22,
_expect(idle_drop > 0.14,
"unarmed idle hands hang below the shoulders (drop %.3f m)"
% idle_drop)
@@ -197,6 +325,69 @@ func _sample_foot_motion(
return greatest
func _torso_chain_bones(model: SkinnedPlayerModel, spine_roles: Array) -> Array:
var role_data: Dictionary = model._rig_info.get("roles", {})
var neck_name := String(role_data.get("neck", ""))
var head_name := String(role_data.get("head", ""))
var chain := [model._role_bone("hips", ["hips", "Hips", "pelvis"])]
for role in spine_roles:
var bone_name := String(role)
if bone_name == neck_name or bone_name == head_name:
continue
chain.append(RigRoles.find_imported_bone(model.skeleton, bone_name))
return chain
func _max_torso_joint_delta(model: SkinnedPlayerModel, chain: Array) -> float:
var greatest := 0.0
for index in range(1, chain.size()):
var parent: int = int(chain[index - 1])
var child: int = int(chain[index])
if parent < 0 or child < 0:
continue
var posed_relative := model.skeleton.get_bone_global_pose(parent) \
.basis.inverse() * model.skeleton.get_bone_global_pose(child).basis
var rest_relative := model.skeleton.get_bone_global_rest(parent) \
.basis.inverse() * model.skeleton.get_bone_global_rest(child).basis
greatest = maxf(greatest,
posed_relative.get_rotation_quaternion().angle_to(
rest_relative.get_rotation_quaternion()))
return greatest
func _max_leg_horizontal_ratio(model: SkinnedPlayerModel,
thigh_l: int, thigh_r: int, shin_l: int, shin_r: int) -> float:
var greatest := 0.0
for pair in [[thigh_l, shin_l], [thigh_r, shin_r]]:
var segment := _world_bone_segment(
model, int(pair[0]), int(pair[1]))
if segment.length() > 0.0001:
greatest = maxf(greatest,
Vector2(segment.x, segment.z).length() / segment.length())
return greatest
func _world_bone_segment(model: SkinnedPlayerModel,
from_bone: int, to_bone: int) -> Vector3:
var local_segment := model.skeleton.get_bone_global_pose(to_bone).origin \
- model.skeleton.get_bone_global_pose(from_bone).origin
return model.skeleton.global_transform.basis * local_segment
func _world_pose_rise(model: SkinnedPlayerModel, top: int, hips: int) -> float:
var basis := model.skeleton.global_transform
var top_world := basis * model.skeleton.get_bone_global_pose(top).origin
var hips_world := basis * model.skeleton.get_bone_global_pose(hips).origin
return top_world.y - hips_world.y
func _world_rest_rise(model: SkinnedPlayerModel, top: int, hips: int) -> float:
var basis := model.skeleton.global_transform
var top_world := basis * model.skeleton.get_bone_global_rest(top).origin
var hips_world := basis * model.skeleton.get_bone_global_rest(hips).origin
return top_world.y - hips_world.y
func _settle_grapple(model: SkinnedPlayerModel, anchor: Vector3,
velocity: Vector3) -> void:
for _frame in SETTLE_FRAMES:
@@ -224,6 +415,64 @@ func _sample_grapple_foot_motion(
return greatest
func _wall_entry_leg_rise(model: SkinnedPlayerModel, side: float,
thigh_l: int, thigh_r: int, shin_l: int, shin_r: int) -> float:
# Enter from the airborne pose so this exercises the exact full-body
# transition that used to rotate the right-wall waist and legs through
# vertical before the authored loop settled.
model.set_wall_side(side)
for _frame in 24:
model.update_state("air", 4.0, false)
await process_frame
var greatest_rise := -INF
for _frame in 20:
model.update_state("wall_run", WALL_GLIDE_SPEED, false)
await process_frame
var leg_l := _world_bone_segment(model, thigh_l, shin_l)
var leg_r := _world_bone_segment(model, thigh_r, shin_r)
greatest_rise = maxf(greatest_rise, maxf(leg_l.y, leg_r.y))
return greatest_rise
func _wall_exit_leg_rise(model: SkinnedPlayerModel,
thigh_l: int, thigh_r: int, shin_l: int, shin_r: int) -> float:
# Start on the problematic right-wall performance, then leave into the
# ground locomotion state and measure the transition frames.
model.set_wall_side(1.0)
for _frame in SETTLE_FRAMES:
model.update_state("wall_run", WALL_GLIDE_SPEED, false)
await process_frame
var greatest_rise := -INF
for _frame in 20:
model.update_state("ground", WALL_GLIDE_SPEED, false)
await process_frame
var leg_l := _world_bone_segment(model, thigh_l, shin_l)
var leg_r := _world_bone_segment(model, thigh_r, shin_r)
greatest_rise = maxf(greatest_rise, maxf(leg_l.y, leg_r.y))
return greatest_rise
func _wall_jump_exit_leg_rise(model: SkinnedPlayerModel,
thigh_l: int, thigh_r: int, shin_l: int, shin_r: int) -> float:
# The real controller reports a positive parent velocity on the first frame
# after a wall jump, so this exercises WallRun -> Jump rather than Fall.
model.set_wall_side(1.0)
for _frame in SETTLE_FRAMES:
model.update_state("wall_run", WALL_GLIDE_SPEED, false)
await process_frame
var body := model.get_parent() as CharacterBody3D
var greatest_rise := -INF
for _frame in 20:
if body:
body.velocity.y = 8.0
model.update_state("air", WALL_GLIDE_SPEED, false)
await process_frame
var leg_l := _world_bone_segment(model, thigh_l, shin_l)
var leg_r := _world_bone_segment(model, thigh_r, shin_r)
greatest_rise = maxf(greatest_rise, maxf(leg_l.y, leg_r.y))
return greatest_rise
func _expect(ok: bool, description: String) -> void:
if ok:
print(" OK: ", description)
+4
View File
@@ -42,6 +42,7 @@ var is_grapple_shooting: bool = false
# ── State tracking ────────────────────────────────────────────────────────────
var wall_normal: Vector3 = Vector3.ZERO
var wall_contact_point: Vector3 = Vector3.ZERO
var wall_side: float = 0.0 # -1 left, +1 right, 0 none
var on_ground: bool = false
var last_ground_time: float = 0.0
@@ -268,6 +269,7 @@ func detect_wall_horizontal() -> Vector3:
# Wall must be roughly vertical (normal mostly horizontal)
if abs(n.y) < 0.3 and n.length_squared() > 0.0:
wall_normal = n.normalized()
wall_contact_point = hit.get("position", origin)
wall_side = side_data["side"]
return wall_normal
@@ -288,10 +290,12 @@ func detect_wall_horizontal() -> Vector3:
var n: Vector3 = hit.get("normal", Vector3.ZERO)
if abs(n.y) < 0.3 and n.length_squared() > 0.0:
wall_normal = n.normalized()
wall_contact_point = hit.get("position", origin)
wall_side = side_data["side"]
return wall_normal
wall_normal = Vector3.ZERO
wall_contact_point = Vector3.ZERO
wall_side = 0.0
return Vector3.ZERO
+9
View File
@@ -89,6 +89,8 @@ var synced_position: Vector3 = Vector3.ZERO
var synced_velocity: Vector3 = Vector3.ZERO
var synced_is_ads: bool = false
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-glide bank)
var synced_wall_normal: Vector3 = Vector3.ZERO
var synced_wall_contact_point: Vector3 = Vector3.ZERO
var synced_helmet_closed: bool = false
var synced_is_dancing: bool = false # dance emote (B), shown on the model
## Which of the five routines in DanceRoutines is playing. Replicated, so other
@@ -1071,6 +1073,8 @@ func _physics_process(_delta: float) -> void:
# otherwise the first movement frame still samples last frame's Idle.
if visual.has_method("set_wall_side"):
visual.set_wall_side(sm.wall_side)
if visual.has_method("set_wall_surface"):
visual.set_wall_surface(sm.wall_normal, sm.wall_contact_point)
if visual.has_method("set_wall_glide_motion"):
visual.set_wall_glide_motion(velocity)
elif visual.has_method("set_wall_run_motion"):
@@ -1089,6 +1093,8 @@ func _physics_process(_delta: float) -> void:
synced_movement_speed = Vector2(velocity.x, velocity.z).length()
synced_is_crouching = sm.input_crouch
synced_wall_side = sm.wall_side
synced_wall_normal = sm.wall_normal
synced_wall_contact_point = sm.wall_contact_point
synced_position = position
synced_velocity = velocity
@@ -1167,6 +1173,9 @@ func _process(delta: float) -> void:
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
if visual.has_method("set_wall_side"):
visual.set_wall_side(synced_wall_side)
if visual.has_method("set_wall_surface"):
visual.set_wall_surface(
synced_wall_normal, synced_wall_contact_point)
if visual.has_method("set_wall_glide_motion"):
visual.set_wall_glide_motion(synced_velocity)
elif visual.has_method("set_wall_run_motion"):
+2
View File
@@ -153,6 +153,8 @@ 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_wall_normal")
client_rep_config.add_property(":synced_wall_contact_point")
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.
@@ -86,6 +86,8 @@ 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_wall_normal")
client_rep_config.add_property(":synced_wall_contact_point")
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.