Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
2 changed files with 272 additions and 10 deletions
Showing only changes of commit 040b595397 - Show all commits
+110 -10
View File
@@ -135,6 +135,11 @@ var _cur_fwd: float = 0.0
var _cur_ads: float = 0.0
var _cur_slide: 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
## Horizontal speed from the last update_state, so the lean can scale with how
## 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
# Backpedalling: run the cycle BACKWARDS rather than moon-walking with
# the forward clip. The shared library has no authored reverse run, and
# a reversed stride reads correctly for a backpedal.
if is_loco and _cur_fwd < -0.25:
# a reversed stride reads correctly for a backpedal. The hip yaw in
# _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
_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)
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
_cur_wall = lerpf(_cur_wall, wall_target, lean_t)
_update_travel(delta, drive)
_pose_mod.strafe = _cur_strafe
_pose_mod.fwd = _cur_fwd
_pose_mod.travel_yaw = _cur_travel
_pose_mod.ads = _cur_ads
_pose_mod.slide = _cur_slide
_pose_mod.wall = _cur_wall
@@ -742,6 +752,56 @@ func _process(delta: float) -> void:
_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.
##
## 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
## 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.
func current_clip_debug() -> String:
return _current_clip
@@ -971,6 +1037,9 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var ads: float = 0.0 # 0 hip .. 1 aiming
var slide: float = 0.0 # 0 .. 1 slide blend
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 recoil: float = 0.0 # decaying shot kick
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
# travelling — the library has one forward locomotion cycle and no strafe
# 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_PITCH := 0.30
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"]:
_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:
var pitch := fwd * LEAN_PITCH * (1.0 - slide)
var roll := strafe * LEAN_ROLL * (1.0 - slide)
if absf(pitch) < 0.001 and absf(roll) < 0.001:
var yaw := travel_yaw * (1.0 - slide)
# How hard the character is driving, regardless of which way. Signed
# `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
var q := Quaternion(Vector3(1, 0, 0), pitch) * Quaternion(Vector3(0, 0, 1), roll)
var per := Quaternion.IDENTITY.slerp(q, 1.0 / SPINE.size())
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
var turn := Quaternion(Vector3(0, 1, 0), yaw)
# Pitch about the axis ACROSS the direction of travel, not across the
# facing — see the comment above the function.
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,
# legs trail behind, and the FREE (left) hand reaches up the rope — the
+162
View File
@@ -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")