extends Node3D @export var left_pupil_path: NodePath = NodePath("Body/HeadPivot/EyeLeft/Pupil") @export var right_pupil_path: NodePath = NodePath("Body/HeadPivot/EyeRight/Pupil") @export var camera_path: NodePath @export var look_origin_path: NodePath = NodePath("Body/HeadPivot") @export var look_reference_path: NodePath = NodePath("Body") @export var look_target_path: NodePath = NodePath("") @export var lock_vertical: bool = true @export var vertical_unlock_height: float = 0.6 @export var vertical_lock_smooth_speed: float = 6.0 @export var vertical_lock_hold_time: float = 0.3 @export var max_look_angle_deg: float = 90.0 @export var eye_return_speed: float = 0.2 @export var max_offset: float = 0.08 @export var side_eye_boost: float = 1.4 @export var head_path: NodePath = NodePath("Body/HeadPivot") @export var head_turn_speed: float = 16.0 @export var head_max_yaw_deg: float = 55.0 @export var head_max_pitch_deg: float = 22.0 var _left_pupil: Node3D var _right_pupil: Node3D var _left_base: Vector3 var _right_base: Vector3 var _camera: Camera3D var _look_origin: Node3D var _head: Node3D var _head_base_rot: Vector3 var _vertical_lock_factor: float = 1.0 var _vertical_hold_timer: float = 0.0 var _look_reference: Node3D var _look_target: Node3D func _ready() -> void: _left_pupil = get_node_or_null(left_pupil_path) as Node3D _right_pupil = get_node_or_null(right_pupil_path) as Node3D if _left_pupil: _left_base = _left_pupil.position if _right_pupil: _right_base = _right_pupil.position _camera = _resolve_camera() _look_origin = get_node_or_null(look_origin_path) as Node3D _look_reference = get_node_or_null(look_reference_path) as Node3D _look_target = get_node_or_null(look_target_path) as Node3D _head = get_node_or_null(head_path) as Node3D if _head: _head_base_rot = _head.rotation func _physics_process(_delta: float) -> void: _update_pupils() func _process(_delta: float) -> void: _update_pupils() func _update_pupils() -> void: if _look_target == null and look_target_path != NodePath(""): _look_target = get_node_or_null(look_target_path) as Node3D if _look_target == null: var viewport_cam := get_viewport().get_camera_3d() if viewport_cam != null and viewport_cam != _camera: _camera = viewport_cam elif _camera == null or not _camera.is_inside_tree(): _camera = _resolve_camera() if _look_target == null and _camera == null: return var origin := _look_origin if origin == null: origin = self var target := _look_target.global_position if _look_target != null else _camera.global_position var dir_world := target - origin.global_position if dir_world.length_squared() <= 0.0001: return dir_world = dir_world.normalized() var reference := _look_reference if _look_reference != null else origin var forward := -reference.global_basis.z var min_dot := cos(deg_to_rad(max_look_angle_deg)) var can_look := dir_world.dot(forward) >= min_dot if not can_look: var delta := get_process_delta_time() if _left_pupil: var target_left := Vector3(_left_base.x, _left_base.y, _left_base.z) var pos_left := _left_pupil.position pos_left.x = move_toward(pos_left.x, target_left.x, eye_return_speed * delta) pos_left.y = target_left.y pos_left.z = move_toward(pos_left.z, target_left.z, eye_return_speed * delta) _left_pupil.position = pos_left if _right_pupil: var target_right := Vector3(_right_base.x, _right_base.y, _right_base.z) var pos_right := _right_pupil.position pos_right.x = move_toward(pos_right.x, target_right.x, eye_return_speed * delta) pos_right.y = target_right.y pos_right.z = move_toward(pos_right.z, target_right.z, eye_return_speed * delta) _right_pupil.position = pos_right if _head: _head.rotation.x = _head_base_rot.x _head.rotation.y = lerp_angle(_head.rotation.y, _head_base_rot.y, head_turn_speed * delta) return if lock_vertical: var origin_y := origin.global_position.y var target_offset := target.y - origin_y var is_above := target_offset > vertical_unlock_height if is_above: _vertical_hold_timer = vertical_lock_hold_time else: _vertical_hold_timer = max(0.0, _vertical_hold_timer - get_process_delta_time()) if is_above: _vertical_lock_factor = 1.0 else: var unlock := 1.0 if _vertical_hold_timer > 0.0 else 0.0 _vertical_lock_factor = move_toward(_vertical_lock_factor, unlock, vertical_lock_smooth_speed * get_process_delta_time()) target.y = lerp(origin_y, target.y, _vertical_lock_factor) dir_world = target - origin.global_position if dir_world.length_squared() <= 0.0001: return dir_world = dir_world.normalized() if _left_pupil: _update_eye(_left_pupil, _left_base, dir_world) if _right_pupil: _update_eye(_right_pupil, _right_base, dir_world) if _head: _update_head(dir_world) func _resolve_camera() -> Camera3D: if camera_path != NodePath(""): var from_path := get_node_or_null(camera_path) as Camera3D if from_path: return from_path var viewport_cam := get_viewport().get_camera_3d() if viewport_cam: return viewport_cam var by_name := get_tree().get_root().find_child("Camera3D", true, false) as Camera3D return by_name func _update_eye(eye: Node3D, base_pos: Vector3, dir_world: Vector3) -> void: var parent := eye.get_parent() as Node3D if parent == null: return var dir_local := parent.global_basis.inverse() * dir_world var flat := Vector2(dir_local.x, dir_local.y) flat.x *= side_eye_boost if flat.length() > 1.0: flat = flat.normalized() var offset := Vector3(flat.x, flat.y, 0.0) * max_offset eye.position = base_pos + offset func _update_head(dir_world: Vector3) -> void: var parent := _head.get_parent() as Node3D if parent == null: return var dir_local := parent.global_basis.inverse() * dir_world var yaw := atan2(-dir_local.x, -dir_local.z) var pitch := atan2(dir_local.y, -dir_local.z) yaw = clamp(yaw, deg_to_rad(-head_max_yaw_deg), deg_to_rad(head_max_yaw_deg)) pitch = clamp(pitch, deg_to_rad(-head_max_pitch_deg), deg_to_rad(head_max_pitch_deg)) var target := Vector3(_head_base_rot.x + pitch, _head_base_rot.y + yaw, _head_base_rot.z) _head.rotation.x = lerp_angle(_head.rotation.x, target.x, head_turn_speed * get_process_delta_time()) _head.rotation.y = lerp_angle(_head.rotation.y, target.y, head_turn_speed * get_process_delta_time())