From 833e936bcd3197dc9d961f6355dbc93bee400e1b Mon Sep 17 00:00:00 2001 From: Nicholas Butzke <137417822+DottsGit@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:17:59 -0400 Subject: [PATCH] ani fixed --- characters/rig_roles.gd | 1 + characters/skinned_player_model.gd | 127 +++++++- debug/test_level_builder.gd | 2 + debug/titanfall_motion_capture.gd | 152 ++++++++++ debug/titanfall_motion_capture.gd.uid | 1 + debug/traversal_animation_check.gd | 277 +++++++++++++++++- movement/movement_state_machine.gd | 4 + movement/player_movement_controller.gd | 9 + scenes/maps/level_runtime.gd | 2 + .../procedural_arena_runtime.gd | 2 + 10 files changed, 554 insertions(+), 23 deletions(-) create mode 100644 debug/titanfall_motion_capture.gd create mode 100644 debug/titanfall_motion_capture.gd.uid diff --git a/characters/rig_roles.gd b/characters/rig_roles.gd index 28cae28..cc9b8c0 100644 --- a/characters/rig_roles.gd +++ b/characters/rig_roles.gd @@ -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", ] diff --git a/characters/skinned_player_model.gd b/characters/skinned_player_model.gd index a6bb61d..c28460c 100644 --- a/characters/skinned_player_model.gd +++ b/characters/skinned_player_model.gd @@ -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 diff --git a/debug/test_level_builder.gd b/debug/test_level_builder.gd index 11e1c93..34dde97 100644 --- a/debug/test_level_builder.gd +++ b/debug/test_level_builder.gd @@ -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. diff --git a/debug/titanfall_motion_capture.gd b/debug/titanfall_motion_capture.gd new file mode 100644 index 0000000..a157882 --- /dev/null +++ b/debug/titanfall_motion_capture.gd @@ -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 -- [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, + } diff --git a/debug/titanfall_motion_capture.gd.uid b/debug/titanfall_motion_capture.gd.uid new file mode 100644 index 0000000..51fdd76 --- /dev/null +++ b/debug/titanfall_motion_capture.gd.uid @@ -0,0 +1 @@ +uid://c6lj5365imsrn diff --git a/debug/traversal_animation_check.gd b/debug/traversal_animation_check.gd index 372cc84..b69593e 100644 --- a/debug/traversal_animation_check.gd +++ b/debug/traversal_animation_check.gd @@ -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) diff --git a/movement/movement_state_machine.gd b/movement/movement_state_machine.gd index f68908d..9b8115f 100644 --- a/movement/movement_state_machine.gd +++ b/movement/movement_state_machine.gd @@ -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 diff --git a/movement/player_movement_controller.gd b/movement/player_movement_controller.gd index b6bd864..4b34caa 100644 --- a/movement/player_movement_controller.gd +++ b/movement/player_movement_controller.gd @@ -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"): diff --git a/scenes/maps/level_runtime.gd b/scenes/maps/level_runtime.gd index faeb0c1..17fea14 100644 --- a/scenes/maps/level_runtime.gd +++ b/scenes/maps/level_runtime.gd @@ -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. diff --git a/scenes/maps/procedural_arena/procedural_arena_runtime.gd b/scenes/maps/procedural_arena/procedural_arena_runtime.gd index 6ee2517..c1473f5 100644 --- a/scenes/maps/procedural_arena/procedural_arena_runtime.gd +++ b/scenes/maps/procedural_arena/procedural_arena_runtime.gd @@ -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.