feat(client): follow-facing — body commits to path, mouse drives layered head/torso look-at (T-1088)

During an RMB path-follow the body yaw locks to the leg direction (rig
commit_body_to_motion, from interpolated velocity — immune to SetFacing
interleaving between throttled steps). The mouse instead drives a layered
look-at: LookAtModifier3D pair on Head (±70°) + spine_02 (±30° torso twist
for looking far lateral/behind — past their sum the character physically
cannot look further without turning). Forward axis measured from the
armature rest pose (+Z), not guessed. Influence fades in/out on follow
start/end. Pure client presentation per D-249; SetFacing still rides the
wire, so the server vision cone follows the mouse while walking — the
character looks where the player points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 20:35:43 +02:00
co-authored by Claude Fable 5
parent 72f88dae1d
commit cb6b39331d
7 changed files with 180 additions and 4 deletions
+16 -4
View File
@@ -62,6 +62,10 @@ var step_window_ms_provider: Callable = Callable()
## unset (NPCs) to collapse the facing split to pure wire facing — single code
## path by construction.
var idle_facing_provider: Callable = Callable()
## T-1088 follow-facing: while a client-initiated path-follow is active the
## body yaw commits to the leg direction (mouse -> head_look, not body).
## Set/cleared by the sandbox root from PathFollower.is_active().
var commit_body_to_motion: bool = false
## () -> bool, true while input is suppressed. The player adapter returns
## GameState.dialogue_active or GameState.free_camera_mode — the exact condition
## under which InputMapper computes but does not send octants (input_mapper.gd:71).
@@ -197,10 +201,18 @@ func _update_facing(delta: float) -> void:
var suppressed := false
if suppression_provider.is_valid():
suppressed = bool(suppression_provider.call())
var idle_octant := ""
if idle_facing_provider.is_valid():
idle_octant = str(idle_facing_provider.call())
yaw_target = select_yaw_target(is_moving, wire_octant, idle_octant, suppressed, yaw_target)
if commit_body_to_motion and is_moving and velocity.length_squared() > 0.0001:
# Path-follow (T-1088 follow-facing refinement): the body is committed to
# the leg direction — the mouse drives the head/torso look-at instead
# (head_look.gd), and SetFacing still steers the server vision cone.
# Leg direction in WorldRoot-local space -> yaw via the same convention
# as SandboxSpace (local +X = sim East, +Z = sim South).
yaw_target = SandboxSpace.sim_angle_to_yaw(atan2(velocity.z, velocity.x))
else:
var idle_octant := ""
if idle_facing_provider.is_valid():
idle_octant = str(idle_facing_provider.call())
yaw_target = select_yaw_target(is_moving, wire_octant, idle_octant, suppressed, yaw_target)
if model_root == null:
return
var budget_key := stance if is_moving else "Idle"