class_name GameplayCamera extends Camera3D @export var target: Node3D @export var target_distance: float = 5.0 @export var target_min_distance: float = 2.0 @export var target_max_distance: float = 10.0 @export var target_decentering_factor: float = 0.4 @export var target_leveling_bias: float = 20.0 @export var allow_losing_target := false @export var follow_lerp_speed: float = 0.999 @export var turn_lerp_speed: float = 0.999 @export var fov_lerp_speed: float = 0.999 @export var clip_margin: float = 0.5 @export var gimbal_margin: float = 0.75 @export var player_control := false @export var look_speed: float = 1.0 @export var look_lerp_speed: float = 0.99 @export var zoom_speed: float = 4.0 @export var mouse_sensitivity: float = 0.03125 @export var mouse_wheel_sensitivity: float = 5.0 @export var analog_sensitivity: float = 1.5 @export var analog_zoom_sensitivity: float = 1.0 var target_direction := Vector3.ZERO var look_impetus := Vector3.ZERO var look_velocity := Vector3.ZERO @onready var _raycast_object := PhysicsRayQueryParameters3D.new() func _ready() -> void: snap_to_target() func _update_mouse_mode() -> void: if player_control && !Engine.is_embedded_in_editor(): Input.mouse_mode = Input.MOUSE_MODE_CAPTURED else: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE func _unhandled_input(event: InputEvent) -> void: if !player_control || Engine.is_embedded_in_editor(): return var move_event := event as InputEventMouseMotion var button_event := event as InputEventMouseButton if move_event: look_impetus.x -= move_event.relative.x*mouse_sensitivity look_impetus.y += move_event.relative.y*mouse_sensitivity elif button_event: match button_event.button_index: MOUSE_BUTTON_WHEEL_UP: look_impetus.z -= mouse_wheel_sensitivity MOUSE_BUTTON_WHEEL_DOWN: look_impetus.z += mouse_wheel_sensitivity func _handle_analog_input() -> void: if !player_control: return look_impetus.x -= ( Input.get_axis(&'look_left', &'look_right')*analog_sensitivity ) look_impetus.y += ( Input.get_axis(&'look_down', &'look_up')*analog_sensitivity ) look_impetus.z += ( Input.get_axis(&'zoom_in', &'zoom_out')*analog_sensitivity ) func _respond_to_impetus(delta: float) -> void: if !target: return var gck: float = _gimbal_check() if ( (look_impetus.y > 0.0 and gck < -gimbal_margin) || (look_impetus.y < 0.0 and gck > gimbal_margin) ): look_impetus.y = 0.0 var desired_look_velocity := Vector3( look_impetus.x*look_speed*target_distance, look_impetus.y*look_speed*target_distance, look_impetus.z*zoom_speed ) look_impetus = look_impetus.limit_length() look_velocity = lerp(look_velocity, desired_look_velocity, look_lerp_speed) _try_go( global_position + global_basis.x*look_velocity.x*delta + global_basis.y*look_velocity.y*delta ) target_distance = clamp( target_distance + look_velocity.z*delta, target_min_distance, target_max_distance ) look_impetus = Vector3.ZERO func _find_closest_safe_spot( where: Vector3, from: Vector3 = global_position ) -> Vector3: _raycast_object.from = from _raycast_object.to = where + ( (where - from).normalized()*clip_margin ) if target: _raycast_object.exclude = [target] else: _raycast_object.exclude = [] var raycast_results := ( get_world_3d().direct_space_state.intersect_ray(_raycast_object) ) var desired_position: Vector3 if raycast_results.is_empty(): desired_position = where else: desired_position = raycast_results.position + ( raycast_results.normal*clip_margin ) if target && !allow_losing_target: allow_losing_target = true var posn_a := _find_closest_safe_spot( desired_position, target.global_position ) if target is Character && target.is_node_ready(): var posn_b := _find_closest_safe_spot( desired_position, target.global_position + target.height*target.global_basis.y ) if ( (posn_b - desired_position).length() < (posn_a - desired_position).length() ): posn_a = posn_b allow_losing_target = false return posn_a else: return desired_position func _try_go(where: Vector3) -> void: global_position = _find_closest_safe_spot(where) func snap_to_target() -> void: if !target: return target_direction = ( -target.global_basis.z - target.global_basis.y/2.0 ).normalized() _follow_target(0.0, true) func _gimbal_check() -> float: return target_direction.dot(target.global_basis.y) func _follow_target( delta: float, snap: bool = false ) -> void: if !target: return var velocity := Vector3.ZERO var top_speed: float = 30.0 if target is RigidBody3D: velocity = (target as RigidBody3D).linear_velocity if target is Character: top_speed = (target as Character).get_top_speed() var decentering: float = target_distance*target_decentering_factor var leveling := ( target_leveling_bias*velocity.project(target.global_basis.y) )*delta var target_position := ( target.global_position + decentering*target.global_basis.y + velocity*delta + leveling ) var target_offset := target_position - global_position if !snap: target_direction = target_offset.normalized() var desired_position := target_position - target_distance*target_direction _try_go(desired_position if snap else lerp( global_position, desired_position, 1.0 - (1.0 - follow_lerp_speed)**delta )) if !is_zero_approx(1.0 - abs(_gimbal_check())): var desired_basis := Basis.looking_at( target_offset - leveling, target.global_basis.y ) global_basis = desired_basis if snap else global_basis.slerp( desired_basis, 1.0 - (1.0 - turn_lerp_speed)**delta ) var desired_fov: float = min( 75.0 + 15.0*velocity.project(global_basis.z).length()/top_speed, 179.0 ) fov = desired_fov if snap else lerp( fov, desired_fov, 1.0 - (1.0 - fov_lerp_speed)**delta ) func _physics_process(delta: float) -> void: _update_mouse_mode() _handle_analog_input() _respond_to_impetus(delta) _follow_target(delta)