Files
settled-reach/client/scripts/sandbox/head_look.gd
T
jpmschweitzerandClaude Fable 5 cb6b39331d 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>
2026-07-06 20:35:43 +02:00

58 lines
2.3 KiB
GDScript

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)