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)