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:
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user