feat(anim): point the legs where the character is actually going
The clip library has one forward locomotion cycle and no strafe or backpedal clips, so a character sidestepping ran forwards on the spot while sliding sideways. Nothing in the animation said which way they were travelling and a body lean was carrying the whole burden of telling the player. Yaw the HIPS onto the travel direction and unwind it up the spine. The legs hang off the hips, so the whole stride turns with them and it costs no new animation; the chest keeps facing roughly where the player aims. Past about a right angle the hips cannot follow, so the cycle plays in reverse and the legs point the other way — a real backpedal instead of a moonwalk. The regime is hysteretic and the yaw is eased, so crossing between them reads as a pivot, which is what a person does there. The lean moved into the travel frame with it. Leaning "forward" along the facing while the legs run off to one side leans them sideways relative to their own stride, which is what being dragged rather than running feels like. It is also driven by how hard the character is moving rather than by signed forward input, so a sidestep leans into its own stride instead of standing straight up. debug/travel_dir_check.gd measures it. How far the stride points from the actual direction of travel: forward 9° fwd-diagonals 5-6° backpedal 2° back-diagonal 2° pure sidestep 39-42°, which is the deliberate hip cap Two things worth knowing about that tool. It reads bone poses from inside the modifier pass, because Godot restores them afterwards and anything read later is the animation with the pose layer missing. And it averages over a full stride: a run cycle twists the torso against the hips by tens of degrees twice per stride, so a single-frame sample measures the clip, not the layer. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0dd9d01ac7
commit
040b595397
@@ -135,6 +135,11 @@ var _cur_fwd: float = 0.0
|
|||||||
var _cur_ads: float = 0.0
|
var _cur_ads: float = 0.0
|
||||||
var _cur_slide: float = 0.0
|
var _cur_slide: float = 0.0
|
||||||
var _cur_wall: float = 0.0
|
var _cur_wall: float = 0.0
|
||||||
|
## Which way the legs are actually travelling, in radians about the character's
|
||||||
|
## own up axis, and whether the locomotion cycle is running backwards to achieve
|
||||||
|
## it. See _update_travel.
|
||||||
|
var _cur_travel: float = 0.0
|
||||||
|
var _travel_reverse: bool = false
|
||||||
var _owner_visible: bool = false
|
var _owner_visible: bool = false
|
||||||
## Horizontal speed from the last update_state, so the lean can scale with how
|
## Horizontal speed from the last update_state, so the lean can scale with how
|
||||||
## fast the character is really moving.
|
## fast the character is really moving.
|
||||||
@@ -612,8 +617,11 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
|||||||
is_loco = true
|
is_loco = true
|
||||||
# Backpedalling: run the cycle BACKWARDS rather than moon-walking with
|
# Backpedalling: run the cycle BACKWARDS rather than moon-walking with
|
||||||
# the forward clip. The shared library has no authored reverse run, and
|
# the forward clip. The shared library has no authored reverse run, and
|
||||||
# a reversed stride reads correctly for a backpedal.
|
# a reversed stride reads correctly for a backpedal. The hip yaw in
|
||||||
if is_loco and _cur_fwd < -0.25:
|
# _update_travel picks the regime and points the legs to match, so the
|
||||||
|
# two must agree — reading a different threshold here used to leave a
|
||||||
|
# band where the feet ran one way and pointed the other.
|
||||||
|
if is_loco and _travel_reverse:
|
||||||
s = -s
|
s = -s
|
||||||
_anim_tree.set("parameters/loco_scale/scale", s)
|
_anim_tree.set("parameters/loco_scale/scale", s)
|
||||||
|
|
||||||
@@ -671,8 +679,10 @@ func _process(delta: float) -> void:
|
|||||||
_cur_slide = lerpf(_cur_slide, slide_target, t)
|
_cur_slide = lerpf(_cur_slide, slide_target, t)
|
||||||
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
|
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
|
||||||
_cur_wall = lerpf(_cur_wall, wall_target, lean_t)
|
_cur_wall = lerpf(_cur_wall, wall_target, lean_t)
|
||||||
|
_update_travel(delta, drive)
|
||||||
_pose_mod.strafe = _cur_strafe
|
_pose_mod.strafe = _cur_strafe
|
||||||
_pose_mod.fwd = _cur_fwd
|
_pose_mod.fwd = _cur_fwd
|
||||||
|
_pose_mod.travel_yaw = _cur_travel
|
||||||
_pose_mod.ads = _cur_ads
|
_pose_mod.ads = _cur_ads
|
||||||
_pose_mod.slide = _cur_slide
|
_pose_mod.slide = _cur_slide
|
||||||
_pose_mod.wall = _cur_wall
|
_pose_mod.wall = _cur_wall
|
||||||
@@ -742,6 +752,56 @@ func _process(delta: float) -> void:
|
|||||||
_pose_mod.reload_phase = rl_target
|
_pose_mod.reload_phase = rl_target
|
||||||
|
|
||||||
|
|
||||||
|
## Which way the LEGS should point, and whether the stride runs backwards.
|
||||||
|
##
|
||||||
|
## The shared clip library has one forward locomotion cycle and no strafe or
|
||||||
|
## backpedal clips, so a character sidestepping used to run forwards on the spot
|
||||||
|
## while sliding sideways — nothing in the animation said which way they were
|
||||||
|
## going, and a body lean was carrying the whole burden of telling the player.
|
||||||
|
##
|
||||||
|
## Turning the HIPS toward the travel direction is what actually says it, and it
|
||||||
|
## costs no new animation: the legs are children of the hips, so the whole stride
|
||||||
|
## turns with them, while the spine counter-rotates so the chest and the gun stay
|
||||||
|
## on the aim. It is the same split every third-person shooter uses, and the same
|
||||||
|
## one Hoyoverse's locked-on locomotion uses.
|
||||||
|
##
|
||||||
|
## Beyond about a right angle the hips cannot follow, so the cycle plays in
|
||||||
|
## REVERSE and the legs point the other way instead — a real backpedal rather
|
||||||
|
## than a moonwalk. Which regime is in force is hysteretic, and the yaw is eased
|
||||||
|
## rather than snapped, so switching between them reads as a pivot on the spot,
|
||||||
|
## which is what a person actually does there.
|
||||||
|
## The most the hips may turn away from where the character is facing.
|
||||||
|
##
|
||||||
|
## A right angle would point the legs exactly along a sidestep, but a person
|
||||||
|
## sidestepping does not stand with their hips square to their path — they open
|
||||||
|
## maybe half that and let the feet cross. Past this the silhouette stops reading
|
||||||
|
## as a shooter holding an aim and starts reading as someone who has turned round.
|
||||||
|
const MAX_TRAVEL_YAW := 0.95
|
||||||
|
const TRAVEL_SMOOTH := 9.0
|
||||||
|
const TRAVEL_REVERSE_IN := -0.35
|
||||||
|
const TRAVEL_REVERSE_OUT := -0.12
|
||||||
|
|
||||||
|
func _update_travel(delta: float, drive: float) -> void:
|
||||||
|
var st: float = _target_strafe
|
||||||
|
var fw: float = _target_fwd
|
||||||
|
if _travel_reverse:
|
||||||
|
if fw > TRAVEL_REVERSE_OUT:
|
||||||
|
_travel_reverse = false
|
||||||
|
elif fw < TRAVEL_REVERSE_IN:
|
||||||
|
_travel_reverse = true
|
||||||
|
var want := 0.0
|
||||||
|
if absf(st) > 0.01 or absf(fw) > 0.01:
|
||||||
|
# Skeleton space: the character faces +Z and character-right is -X, so a
|
||||||
|
# travel direction of (strafe right, forward) is (-strafe, 0, fwd). A yaw
|
||||||
|
# of `want` about +Y points the legs along it — or along the opposite of
|
||||||
|
# it when the stride is running backwards.
|
||||||
|
want = atan2(st, -fw) if _travel_reverse else atan2(-st, fw)
|
||||||
|
want = clampf(want, -MAX_TRAVEL_YAW, MAX_TRAVEL_YAW) * drive
|
||||||
|
# Shortest way round, so a pivot never takes the long route.
|
||||||
|
var d := wrapf(want - _cur_travel, -PI, PI)
|
||||||
|
_cur_travel += d * (1.0 - exp(-TRAVEL_SMOOTH * delta))
|
||||||
|
|
||||||
|
|
||||||
## Pick the cloth solver's detail level from how far the camera is.
|
## 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
|
## Re-checked a few times a second rather than every frame: the answer changes
|
||||||
@@ -802,6 +862,12 @@ func get_lean_debug() -> float:
|
|||||||
return _cur_fwd
|
return _cur_fwd
|
||||||
|
|
||||||
|
|
||||||
|
## Whether the locomotion cycle is running backwards, which points the stride
|
||||||
|
## the opposite way to the hips. For debug/travel_dir_check.gd.
|
||||||
|
func stride_reversed_debug() -> bool:
|
||||||
|
return _travel_reverse
|
||||||
|
|
||||||
|
|
||||||
## Clip currently playing. For debug/transition_check.gd.
|
## Clip currently playing. For debug/transition_check.gd.
|
||||||
func current_clip_debug() -> String:
|
func current_clip_debug() -> String:
|
||||||
return _current_clip
|
return _current_clip
|
||||||
@@ -971,6 +1037,9 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
var ads: float = 0.0 # 0 hip .. 1 aiming
|
var ads: float = 0.0 # 0 hip .. 1 aiming
|
||||||
var slide: float = 0.0 # 0 .. 1 slide blend
|
var slide: float = 0.0 # 0 .. 1 slide blend
|
||||||
var wall: float = 0.0 # -1 wall left .. +1 wall right (wall-run lean)
|
var wall: float = 0.0 # -1 wall left .. +1 wall right (wall-run lean)
|
||||||
|
# Yaw of the LOWER body about the character's up axis, in radians — which way
|
||||||
|
# the legs are actually travelling. See SkinnedPlayerModel._update_travel.
|
||||||
|
var travel_yaw: float = 0.0
|
||||||
var aim_pitch: float = 0.0 # radians, up positive — upper body follows aim
|
var aim_pitch: float = 0.0 # radians, up positive — upper body follows aim
|
||||||
var recoil: float = 0.0 # decaying shot kick
|
var recoil: float = 0.0 # decaying shot kick
|
||||||
var state: String = "idle"
|
var state: String = "idle"
|
||||||
@@ -1000,6 +1069,11 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
# The lean is the ONLY thing that tells a viewer which way this character is
|
# The lean is the ONLY thing that tells a viewer which way this character is
|
||||||
# travelling — the library has one forward locomotion cycle and no strafe
|
# travelling — the library has one forward locomotion cycle and no strafe
|
||||||
# clips — so it has to be legible, not subtle.
|
# clips — so it has to be legible, not subtle.
|
||||||
|
## How much of the hip yaw the spine takes back, so the chest, the head and
|
||||||
|
## the gun stay pointed where the player is aiming. Not all of it: a real
|
||||||
|
## torso does follow the hips a little, and countering the whole thing makes
|
||||||
|
## the waist look broken.
|
||||||
|
const TRAVEL_COUNTER := 0.82
|
||||||
const LEAN_ROLL := 0.42
|
const LEAN_ROLL := 0.42
|
||||||
const LEAN_PITCH := 0.30
|
const LEAN_PITCH := 0.30
|
||||||
const SLIDE_BACK := 0.75 # torso lean-back during slide
|
const SLIDE_BACK := 0.75 # torso lean-back during slide
|
||||||
@@ -1108,16 +1182,42 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
for n in ["DEF-spine.002", "DEF-spine.003"]:
|
for n in ["DEF-spine.002", "DEF-spine.003"]:
|
||||||
_add_space(skel, _idx.get(n, -1), back)
|
_add_space(skel, _idx.get(n, -1), back)
|
||||||
|
|
||||||
# Distribute a skeleton-space lean across the spine bones.
|
# Turn the lower body to face the way the character is travelling, and lean
|
||||||
|
# along that direction rather than along the facing.
|
||||||
|
#
|
||||||
|
# The legs hang off the hips, so yawing the hips turns the whole stride — the
|
||||||
|
# one thing that makes a sidestep look like a sidestep when the clip library
|
||||||
|
# has only a forward run. The spine takes most of it back so the chest and the
|
||||||
|
# gun stay on the aim.
|
||||||
|
#
|
||||||
|
# The LEAN has to move into the travel frame with it. Leaning "forward" along
|
||||||
|
# the character's facing while the legs run off to one side leans them
|
||||||
|
# sideways relative to their own stride, which is exactly the sensation of a
|
||||||
|
# character being dragged rather than running.
|
||||||
func _apply_lean(skel: Skeleton3D) -> void:
|
func _apply_lean(skel: Skeleton3D) -> void:
|
||||||
var pitch := fwd * LEAN_PITCH * (1.0 - slide)
|
var yaw := travel_yaw * (1.0 - slide)
|
||||||
var roll := strafe * LEAN_ROLL * (1.0 - slide)
|
# How hard the character is driving, regardless of which way. Signed
|
||||||
if absf(pitch) < 0.001 and absf(roll) < 0.001:
|
# `fwd` is wrong now that the legs turn: a sidestep has fwd near zero and
|
||||||
|
# should still lean into its own stride.
|
||||||
|
var effort := clampf(Vector2(strafe, fwd).length(), 0.0, 1.0)
|
||||||
|
var pitch := effort * LEAN_PITCH * (1.0 - slide)
|
||||||
|
if absf(pitch) < 0.001 and absf(yaw) < 0.001:
|
||||||
return
|
return
|
||||||
var q := Quaternion(Vector3(1, 0, 0), pitch) * Quaternion(Vector3(0, 0, 1), roll)
|
var turn := Quaternion(Vector3(0, 1, 0), yaw)
|
||||||
var per := Quaternion.IDENTITY.slerp(q, 1.0 / SPINE.size())
|
# Pitch about the axis ACROSS the direction of travel, not across the
|
||||||
for n in SPINE:
|
# facing — see the comment above the function.
|
||||||
_add_space(skel, _idx.get(n, -1), per)
|
var lean := Quaternion((turn * Vector3(1, 0, 0)).normalized(), pitch)
|
||||||
|
# The hips carry the yaw, and a little of the lean.
|
||||||
|
_add_space(skel, _idx.get("DEF-hips", -1),
|
||||||
|
turn * Quaternion.IDENTITY.slerp(lean, 0.25))
|
||||||
|
# The torso unwinds the yaw and takes the rest of the lean.
|
||||||
|
var torso: Array = SPINE.slice(1)
|
||||||
|
var n := maxf(torso.size(), 1)
|
||||||
|
var back := Quaternion(Vector3(0, 1, 0), -yaw * TRAVEL_COUNTER)
|
||||||
|
var per_yaw := Quaternion.IDENTITY.slerp(back, 1.0 / n)
|
||||||
|
var per_lean := Quaternion.IDENTITY.slerp(lean, 0.75 / n)
|
||||||
|
for b in torso:
|
||||||
|
_add_space(skel, _idx.get(b, -1), per_yaw * per_lean)
|
||||||
|
|
||||||
# Grapple zip: the whole body pivots to fly along the line to the anchor,
|
# Grapple zip: the whole body pivots to fly along the line to the anchor,
|
||||||
# legs trail behind, and the FREE (left) hand reaches up the rope — the
|
# legs trail behind, and the FREE (left) hand reaches up the rope — the
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Dev tool: do the LEGS point where the character is going, and does the CHEST
|
||||||
|
## stay pointed where the player is aiming?
|
||||||
|
##
|
||||||
|
## godot --headless --path . -s res://debug/travel_dir_check.gd -- [skin_glb]
|
||||||
|
##
|
||||||
|
## The clip library has one forward locomotion cycle and no strafe or backpedal
|
||||||
|
## clips, so nothing in the animation itself says which way the character is
|
||||||
|
## travelling. SkinnedPlayerModel._update_travel says it by yawing the hips onto
|
||||||
|
## the travel direction and unwinding that yaw up the spine.
|
||||||
|
##
|
||||||
|
## The number that matters is LEGS OFF BY: how far the stride points from the
|
||||||
|
## direction the character is actually travelling. A backpedal is achieved by
|
||||||
|
## running the cycle BACKWARDS with the hips near square, so it is scored on
|
||||||
|
## where the feet go, not on where the hips face.
|
||||||
|
##
|
||||||
|
## CHEST is reported for information only, and is not a pass/fail. Most of it is
|
||||||
|
## the clip's own torso twist — a run cycle counter-rotates the chest against the
|
||||||
|
## hips by tens of degrees twice a stride — and averaging over a stride only
|
||||||
|
## partly removes it. It is also not what keeps the gun on target: the rifle hold
|
||||||
|
## places the weapon from the SHOULDER and the aim direction and then solves the
|
||||||
|
## arms onto it, so the aim survives whatever the torso is doing. Read this
|
||||||
|
## column as "does the upper body still look square-ish", nothing stronger.
|
||||||
|
|
||||||
|
## label, strafe, forward, the direction of travel this should read as (degrees
|
||||||
|
## about the character's up axis, 0 = straight ahead)
|
||||||
|
const CASES := [
|
||||||
|
["forward", 0.0, 1.0, 0.0],
|
||||||
|
["strafe right", 1.0, 0.0, -90.0],
|
||||||
|
["strafe left", -1.0, 0.0, 90.0],
|
||||||
|
["fwd-right", 0.7, 0.7, -45.0],
|
||||||
|
["fwd-left", -0.7, 0.7, 45.0],
|
||||||
|
["backpedal", 0.0, -1.0, 180.0],
|
||||||
|
["back-right", 0.7, -0.7, -135.0],
|
||||||
|
]
|
||||||
|
const SETTLE := 70
|
||||||
|
## Frames averaged at the end of each case — comfortably more than one stride.
|
||||||
|
const MEASURE_OVER := 40
|
||||||
|
|
||||||
|
var _frames := 0
|
||||||
|
var _model: SkinnedPlayerModel = null
|
||||||
|
var _probe = null
|
||||||
|
var _case := 0
|
||||||
|
var _rows: Array = []
|
||||||
|
var _hips_sum := 0.0
|
||||||
|
var _chest_sum := 0.0
|
||||||
|
var _n := 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/taila.glb"
|
||||||
|
var scene := Node3D.new()
|
||||||
|
root.add_child(scene)
|
||||||
|
current_scene = scene
|
||||||
|
_model = SkinnedPlayerModel.new()
|
||||||
|
_model.model_path = path
|
||||||
|
scene.add_child(_model)
|
||||||
|
|
||||||
|
|
||||||
|
## Bone poses read from INSIDE the modifier pass — Godot restores them after it,
|
||||||
|
## so anything read later is the animation alone with the pose layer missing.
|
||||||
|
class PoseProbe extends SkeletonModifier3D:
|
||||||
|
var pose: Array = []
|
||||||
|
|
||||||
|
func _process_modification() -> void:
|
||||||
|
var skel := get_skeleton()
|
||||||
|
if skel == null:
|
||||||
|
return
|
||||||
|
pose.resize(skel.get_bone_count())
|
||||||
|
for i in skel.get_bone_count():
|
||||||
|
pose[i] = skel.get_bone_global_pose(i)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
_frames += 1
|
||||||
|
if _frames < 8 or not _model.loaded:
|
||||||
|
return false
|
||||||
|
var skel: Skeleton3D = _model.skeleton
|
||||||
|
if skel == null:
|
||||||
|
return true
|
||||||
|
if _probe == null:
|
||||||
|
_probe = PoseProbe.new()
|
||||||
|
_probe.name = "TravelProbe"
|
||||||
|
skel.add_child(_probe)
|
||||||
|
return false
|
||||||
|
|
||||||
|
var c: Array = CASES[_case]
|
||||||
|
# Run fast enough that the lean, which scales with speed, is fully in.
|
||||||
|
_model.update_state("ground", 9.0, false)
|
||||||
|
_model.set_locomotion(c[1], c[2], 0.0)
|
||||||
|
# AVERAGED over the tail of each case, not sampled at one instant.
|
||||||
|
#
|
||||||
|
# A run cycle twists the torso against the hips by tens of degrees twice per
|
||||||
|
# stride, all of it authored and correct. Reading a single frame therefore
|
||||||
|
# measures wherever the clip happened to be, and the numbers swing far wider
|
||||||
|
# than anything the pose layer does — a backpedal read as 50 degrees of chest
|
||||||
|
# yaw with the hips almost square, which is the clip, not the layer.
|
||||||
|
var phase: int = (_frames - 9) % SETTLE
|
||||||
|
if phase >= SETTLE - MEASURE_OVER and _probe.pose.size() == skel.get_bone_count():
|
||||||
|
_hips_sum += _yaw_of(skel, "hips")
|
||||||
|
_chest_sum += _yaw_of(skel, "spine_top")
|
||||||
|
_n += 1.0
|
||||||
|
if phase != SETTLE - 1:
|
||||||
|
return false
|
||||||
|
|
||||||
|
if _probe.pose.size() == skel.get_bone_count():
|
||||||
|
_rows.append([c[0], _hips_sum / _n, _chest_sum / _n,
|
||||||
|
float(c[3]), _model.stride_reversed_debug()])
|
||||||
|
_hips_sum = 0.0
|
||||||
|
_chest_sum = 0.0
|
||||||
|
_n = 0.0
|
||||||
|
_case += 1
|
||||||
|
if _case >= CASES.size():
|
||||||
|
_report()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
## Yaw of a bone away from its own rest orientation, in degrees, about the
|
||||||
|
## character's up axis. Measured against REST rather than against the parent, so
|
||||||
|
## it reads as "how far round from square" whatever the rig's bone axes are.
|
||||||
|
func _yaw_of(skel: Skeleton3D, which: String) -> float:
|
||||||
|
var roles: Dictionary = _model._rig_info.get("roles", {})
|
||||||
|
var name := ""
|
||||||
|
if which == "hips":
|
||||||
|
name = String(roles.get("hips", "DEF-hips"))
|
||||||
|
else:
|
||||||
|
var spine: Array = roles.get("spine", [])
|
||||||
|
# The chest: the last spine bone that is not the neck or the head.
|
||||||
|
var neck := String(roles.get("neck", ""))
|
||||||
|
var head := String(roles.get("head", ""))
|
||||||
|
for n in spine:
|
||||||
|
if String(n) != neck and String(n) != head:
|
||||||
|
name = String(n)
|
||||||
|
var b := skel.find_bone(name)
|
||||||
|
if b < 0:
|
||||||
|
return 0.0
|
||||||
|
var now: Basis = (_probe.pose[b] as Transform3D).basis
|
||||||
|
var rest: Basis = skel.get_bone_global_rest(b).basis
|
||||||
|
var delta := rest.inverse() * now
|
||||||
|
# The character faces +Z; how far that axis has swung in the ground plane.
|
||||||
|
var f: Vector3 = rest * (delta * (rest.inverse() * Vector3(0, 0, 1)))
|
||||||
|
return rad_to_deg(atan2(f.x, f.z))
|
||||||
|
|
||||||
|
|
||||||
|
func _report() -> void:
|
||||||
|
print("\n=== does the body say which way it is going? ===")
|
||||||
|
print(" %-13s %9s %9s %9s %s" % [
|
||||||
|
"input", "hips", "chest", "want legs", "stride"])
|
||||||
|
for r in _rows:
|
||||||
|
# A reversed stride points the feet the opposite way to the hips.
|
||||||
|
var legs: float = r[1] + (180.0 if r[4] else 0.0)
|
||||||
|
var err: float = absf(wrapf(legs - r[3], -180.0, 180.0))
|
||||||
|
print(" %-13s %+8.1f° %+8.1f° %+8.1f° %-8s legs off by %5.1f°%s" % [
|
||||||
|
r[0], r[1], r[2], r[3], "reversed" if r[4] else "forward", err,
|
||||||
|
" CHEST OFF AIM" if absf(r[2]) > 25.0 else ""])
|
||||||
|
print("\n 'legs off by' is how far the stride points from the travel")
|
||||||
|
print(" direction. The hips are capped (see MAX_TRAVEL_YAW), so a pure")
|
||||||
|
print(" sidestep is expected to fall short — a shooter keeps its aim.\n")
|
||||||
Reference in New Issue
Block a user