class_name ThirdPersonCameraRig extends Node3D ## Third-person orbit/zoom state and Camera3D presentation adapter. ## The rig owns camera input state and local transforms. Character movement, ## terrain, rendering and collision-policy decisions remain external boundaries. const NO_CAMERA_COLLISION_POLICY_SCRIPT := preload("res://src/scenes/player/no_camera_collision_policy.gd") ## Relative Camera3D child used by this rig. @export var camera_path: NodePath = NodePath("Camera3D") ## Mouse radians applied per input pixel. @export var mouse_sensitivity_radians_per_pixel: float = 0.003 ## Minimum orbit pitch in radians. @export var minimum_pitch_radians: float = deg_to_rad(-65.0) ## Maximum orbit pitch in radians. @export var maximum_pitch_radians: float = deg_to_rad(35.0) ## Pivot height above the character origin in Godot units. @export var pivot_height_units: float = 1.7 ## Initial requested camera distance in Godot units. @export var initial_distance_units: float = 8.0 ## Minimum user-requested zoom distance in Godot units. @export var minimum_distance_units: float = 2.0 ## Maximum user-requested zoom distance in Godot units. @export var maximum_distance_units: float = 18.0 ## Distance change per zoom action in Godot units. @export var zoom_step_units: float = 1.0 ## Camera far plane retained from the renderer sandbox baseline. @export var camera_far_plane_units: float = 50000.0 var yaw_radians: float: get: return _yaw_radians var pitch_radians: float: get: return _pitch_radians var requested_distance_units: float: get: return _requested_distance_units var resolved_distance_units: float: get: return _resolved_distance_units var is_mouse_captured: bool: get: return _is_mouse_captured var _character_body: Node3D var _camera: Camera3D var _collision_policy: CameraCollisionPolicy var _yaw_radians := 0.0 var _pitch_radians := deg_to_rad(-18.0) var _requested_distance_units := 8.0 var _resolved_distance_units := 8.0 var _is_mouse_captured := false func _ready() -> void: _camera = get_node_or_null(camera_path) as Camera3D if _camera == null: push_error("ThirdPersonCameraRig: Camera3D not found at %s" % camera_path) if _collision_policy == null: _collision_policy = NO_CAMERA_COLLISION_POLICY_SCRIPT.new() _pitch_radians = clampf(_pitch_radians, minimum_pitch_radians, maximum_pitch_radians) _requested_distance_units = clampf( initial_distance_units, minimum_distance_units, maximum_distance_units ) if _camera != null: _camera.current = true _camera.far = camera_far_plane_units refresh_camera_transform() func _physics_process(_delta_seconds: float) -> void: refresh_camera_transform() func _exit_tree() -> void: if _is_mouse_captured: _is_mouse_captured = false Input.mouse_mode = Input.MOUSE_MODE_VISIBLE ## Attaches the character transform rotated by orbit input and captures its initial yaw. func initialize_for_character(character_body: Node3D) -> void: _character_body = character_body if _character_body != null: _yaw_radians = _character_body.rotation.y refresh_camera_transform() ## Synchronizes orbit yaw after keyboard turning changes the character transform. func synchronize_yaw_from_character() -> void: if _character_body != null: _yaw_radians = _character_body.rotation.y ## Replaces camera-distance collision behavior and immediately refreshes the camera. ## Passing null is rejected and preserves the current policy. func set_collision_policy(collision_policy: CameraCollisionPolicy) -> void: if collision_policy == null: push_error("ThirdPersonCameraRig: collision policy cannot be null") return _collision_policy = collision_policy refresh_camera_transform() ## Applies remappable camera actions and orbit mouse motion. ## Returns true when the event changed or consumed camera state. func handle_camera_input(event: InputEvent) -> bool: if event.is_action_pressed(PlayerInputActions.CAMERA_ROTATE): _is_mouse_captured = true Input.mouse_mode = Input.MOUSE_MODE_CAPTURED return true if event.is_action_released(PlayerInputActions.CAMERA_ROTATE): _is_mouse_captured = false Input.mouse_mode = Input.MOUSE_MODE_VISIBLE return true if event.is_action_pressed(PlayerInputActions.CAMERA_ZOOM_IN): _requested_distance_units = clampf( _requested_distance_units - zoom_step_units, minimum_distance_units, maximum_distance_units ) refresh_camera_transform() return true if event.is_action_pressed(PlayerInputActions.CAMERA_ZOOM_OUT): _requested_distance_units = clampf( _requested_distance_units + zoom_step_units, minimum_distance_units, maximum_distance_units ) refresh_camera_transform() return true if event.is_action_pressed(PlayerInputActions.RELEASE_CURSOR): _is_mouse_captured = false Input.mouse_mode = Input.MOUSE_MODE_VISIBLE return true if _is_mouse_captured and event is InputEventMouseMotion: _yaw_radians -= event.relative.x * mouse_sensitivity_radians_per_pixel _pitch_radians = clampf( _pitch_radians - event.relative.y * mouse_sensitivity_radians_per_pixel, minimum_pitch_radians, maximum_pitch_radians ) if _character_body != null: _character_body.rotation.y = _yaw_radians refresh_camera_transform() return true return false ## Returns the pivot's Godot-world basis for camera-relative sandbox flight. func godot_world_flight_movement_basis() -> Basis: return global_basis ## Applies pivot and Camera3D local transforms using the active collision policy. func refresh_camera_transform() -> void: position = Vector3(0.0, pivot_height_units, 0.0) rotation.x = _pitch_radians if _camera == null: return var policy_distance := _collision_policy.resolve_camera_distance( self, _requested_distance_units ) if _collision_policy != null else _requested_distance_units _resolved_distance_units = clampf(policy_distance, 0.0, _requested_distance_units) _camera.position = Vector3(0.0, 0.0, _resolved_distance_units) _camera.rotation = Vector3.ZERO