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
+57
View File
@@ -0,0 +1,57 @@
class_name SandboxHeadLook
extends RefCounted
## T-1088 follow-facing refinement: layered mouse look-at while a path-follow
## commits the body to the leg direction (locomotion_rig.commit_body_to_motion).
##
## Two LookAtModifier3D nodes under the CharacterVisual skeleton aim at the
## shared HeadAimTarget: spine_02 contributes a small torso twist (looking far
## lateral/behind), Head does the bulk. Past their combined limits the character
## physically cannot look further without turning — correct. Pure client
## presentation on the D-249 model; SetFacing still rides the wire, so the
## SERVER vision cone follows the mouse while the body walks the path.
##
## Bone axes verified from armature.glb rest pose (bone_axes_dump.py): both
## bones are Y-up with +Z as the facing axis in Godot bone space — forward_axis
## is set from measurement, not guessed (the E/W-mirror lesson).
var _mods: Array[LookAtModifier3D] = []
var _target_influence: float = 0.0
## Create + configure the modifier pair. Call once after CharacterVisual has
## built its skeleton (the sandbox never rebuilds the descriptor; a rebuild
## would free the skeleton and require a re-setup).
func setup(skeleton: Skeleton3D, aim_target: Node3D) -> void:
_mods.clear()
var configs: Array[Dictionary] = [
{"bone": "spine_02", "limit_deg": SandboxConstants.HEAD_LOOK_TORSO_LIMIT_DEG},
{"bone": "Head", "limit_deg": SandboxConstants.HEAD_LOOK_HEAD_LIMIT_DEG},
]
for cfg in configs:
var m := LookAtModifier3D.new()
m.name = "LookAt_%s" % cfg["bone"]
skeleton.add_child(m)
m.bone_name = str(cfg["bone"])
m.target_node = m.get_path_to(aim_target)
m.forward_axis = SkeletonModifier3D.BONE_AXIS_PLUS_Z
m.primary_rotation_axis = Vector3.AXIS_Y
m.use_secondary_rotation = true # slight pitch toward the ground point
m.use_angle_limitation = true
m.symmetry_limitation = true
m.primary_limit_angle = deg_to_rad(float(cfg["limit_deg"]))
m.influence = 0.0
_mods.append(m)
## Fade the look-at in while a follow is active, out otherwise.
func set_active(active: bool) -> void:
_target_influence = 1.0 if active else 0.0
func update(delta: float) -> void:
if _mods.is_empty():
return
var step := delta / maxf(SandboxConstants.HEAD_LOOK_FADE_S, 0.001)
for m in _mods:
if is_instance_valid(m):
m.influence = move_toward(m.influence, _target_influence, step)
+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"
@@ -41,6 +41,11 @@ var _aim_provider: SandboxMouseAimProvider = null
## per frame). Installed in _ready(); the RMB gesture handling is in _unhandled_input.
var _path_preview: PathPreview = null
var _path_follower: PathFollower = null
## T-1088 follow-facing: layered head/torso look-at (active during follows) and
## its shared aim-target node (WorldRoot-local, positioned at the mouse ground
## point each frame).
var _head_look: SandboxHeadLook = null
var _head_aim_target: Node3D = null
## Time (ms) a non-double RMB press began, -1 = none pending. Resolves click vs
## long-press on RELEASE (RMB_LONG_PRESS_MS threshold).
var _rmb_press_msec: int = -1
@@ -97,6 +102,15 @@ func _ready() -> void:
_anim = LocomotionAnim.new()
_anim.setup(player_rig, character_visual)
# (3d) Follow-facing look-at (T-1088): aim target under WorldRoot (its local
# space IS the sim-aligned space ground_hit_local returns) + the modifier
# pair on the freshly-built skeleton.
_head_aim_target = Node3D.new()
_head_aim_target.name = "HeadAimTarget"
world_root.add_child(_head_aim_target)
_head_look = SandboxHeadLook.new()
_head_look.setup(character_visual.get_skeleton(), _head_aim_target)
# (4) InputMapper facing provider (design §9).
_install_facing_provider()
@@ -390,6 +404,32 @@ func _per_frame_update(delta: float) -> void:
_path_preview.update(char_tile, is_floor, greybox.store.size())
_path_follower.tick(char_tile, is_floor)
# T-1088 follow-facing: while a follow is active the body commits to the leg
# direction and the mouse drives the layered head/torso look-at instead.
# SetFacing still rides the wire (the server vision cone follows the mouse).
var follow_active := _path_follower.is_active()
player_rig.commit_body_to_motion = follow_active
_head_look.set_active(follow_active and not _input_suppressed())
_update_head_aim_target()
_head_look.update(delta)
## Position the shared look-at target at the mouse's ground point (WorldRoot-
## local — the same sim-aligned space the aim provider returns) at eye height.
func _update_head_aim_target() -> void:
var cam := get_viewport().get_camera_3d()
if cam == null or _head_aim_target == null:
return
var mouse := get_viewport().get_mouse_position()
var hit: Variant = SandboxMouseAimProvider.ground_hit_local(
cam.project_ray_origin(mouse), cam.project_ray_normal(mouse),
world_root.global_transform
)
if hit != null:
var p: Vector3 = hit
p.y = SandboxConstants.HEAD_LOOK_EYE_HEIGHT_M
_head_aim_target.position = p
## Hard camera-pivot snap (design §7) — first-snapshot latch + teleport fan-out.
func _snap_camera_to_player() -> void:
@@ -121,6 +121,17 @@ const TINTS := {
## Ground-plane deadzone (m) for the mouse facing provider — mirrors the 2D jitter guard.
const MOUSE_AIM_DEADZONE_M := 0.1
# -- Head/torso look-at during path-follow (T-1088 follow-facing) ---------------------
## Head bone yaw limit (deg) for the mouse look-at while the body walks a path.
const HEAD_LOOK_HEAD_LIMIT_DEG := 70.0
## Torso (spine_02) contribution limit (deg) — engages when looking far lateral/behind.
const HEAD_LOOK_TORSO_LIMIT_DEG := 30.0
## Look-at influence fade in/out (s) on follow start/end.
const HEAD_LOOK_FADE_S := 0.25
## Aim point height above the mouse ground hit (m) — roughly eye level.
const HEAD_LOOK_EYE_HEIGHT_M := 1.55
# -- Path preview / click-to-move (T-1088 live-session item 2) ------------------------
## Move-here marker fill on the hovered destination subtile — distinct accent,