class_name OverworldCamera extends Camera3D enum CameraMode { FREE, CENTERED } # const COLLISION_MARGIN:float = 0.1 const COLLISION_MARGIN:float = 0 @export_category("Target") @export var targetNode:Node3D = null @export var pivotOffset:Vector3 = Vector3(0, 1.2, 0) @export_category("Orbit") @export var distance:float = 7.0 @export var minDistance:float = 2.5 @export var orbitSensitivity:float = 144.0 @export var orbitAcceleration:float = 600.0 @export var orbitFriction:float = 10.0 @export var pitchMin:float = -80.0 @export var pitchMax:float = 70.0 @export_category("Mouse") @export var mouseSensitivity:float = 0.3 @export_category("Auto-center") @export var centeredDelay:float = 1.0 @export var centeredFollowRate:float = 0.75 @export var centeredMaxFollowRate:float = 3.0 @export var centeredAcceleration:float = 180.0 @export var centeredMaxYawDiff:float = 120.0 @export var centeredPitch:float = 30.0 @export_category("Collision") @export_flags_3d_physics var collisionMask:int = 1 var _mode:CameraMode = CameraMode.CENTERED var _yaw:float = 0.0 var _pitch:float = 30.0 var _camVelocity:Vector2 = Vector2.ZERO var _centerVelocity:float = 0.0 var _freeTimer:float = 0.0 var _mouseDelta:Vector2 = Vector2.ZERO var _rightMouseHeld:bool = false func _input(event:InputEvent) -> void: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT: _rightMouseHeld = event.pressed Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _rightMouseHeld else Input.MOUSE_MODE_VISIBLE elif event is InputEventMouseMotion and _rightMouseHeld: _mouseDelta += event.relative func _process(delta:float) -> void: if targetNode == null: return var xMult:float = -1.0 if SETTINGS.invertCameraX else 1.0 var yMult:float = 1.0 if SETTINGS.invertCameraY else -1.0 var orbitInput:Vector2 = Input.get_vector( "camera_orbit_left", "camera_orbit_right", "camera_orbit_up", "camera_orbit_down" ) var controllerActive:bool = orbitInput.length() > 0.01 var mouseActive:bool = _mouseDelta.length_squared() > 0.0 # Any manual camera input returns to FREE and resets the centering timer if controllerActive or mouseActive: _mode = CameraMode.FREE _freeTimer = 0.0 # Controller input: ramp up to target speed, then friction-dampen on release if controllerActive: _camVelocity = _camVelocity.move_toward( orbitInput * orbitSensitivity * SETTINGS.cameraSpeedController, orbitAcceleration * delta ) else: _camVelocity = _camVelocity.lerp(Vector2.ZERO, minf(orbitFriction * delta, 1.0)) _yaw += _camVelocity.x * delta * xMult _pitch += _camVelocity.y * delta * yMult # Right-click mouse drag if mouseActive: _yaw += _mouseDelta.x * mouseSensitivity * SETTINGS.cameraSpeedMouse * xMult _pitch += _mouseDelta.y * mouseSensitivity * SETTINGS.cameraSpeedMouse * yMult _mouseDelta = Vector2.ZERO # center_camera input → switch to CENTERED immediately if Input.is_action_just_pressed("center_camera"): _mode = CameraMode.CENTERED # In FREE mode, accumulate time toward auto-centering while the player is moving if _mode == CameraMode.FREE: var playerBody := targetNode as CharacterBody3D if playerBody != null and playerBody.velocity.length_squared() > 0.1: _freeTimer += delta if _freeTimer >= centeredDelay: _mode = CameraMode.CENTERED _freeTimer = 0.0 else: _freeTimer = 0.0 # In CENTERED mode, accelerate a yaw velocity toward behind the player (using # actual movement velocity for direction) then friction-decay when idle — # mirrors how controller input works so there's no sudden stop. if _mode == CameraMode.CENTERED: var centerBody := targetNode as CharacterBody3D var vel3d:Vector3 = Vector3.ZERO if centerBody == null else centerBody.velocity if vel3d.length_squared() > 0.1: var behindYaw:float = rad_to_deg(atan2(-vel3d.x, -vel3d.z)) var centerYawDiff:float = fposmod(behindYaw - _yaw + 180.0, 360.0) - 180.0 var totalAngle:float = abs(centerYawDiff) + abs(centeredPitch - _pitch) var dynamicRate:float = minf(centeredFollowRate * (1.0 + totalAngle / 90.0), centeredMaxFollowRate) if abs(centerYawDiff) <= centeredMaxYawDiff: _centerVelocity = move_toward(_centerVelocity, centerYawDiff * dynamicRate, centeredAcceleration * delta) var tPitch:float = minf(dynamicRate * 3.0 * delta, 1.0) _pitch = lerpf(_pitch, centeredPitch, tPitch) else: _centerVelocity = lerpf(_centerVelocity, 0.0, minf(orbitFriction * delta, 1.0)) else: _centerVelocity = lerpf(_centerVelocity, 0.0, minf(orbitFriction * delta, 1.0)) _yaw += _centerVelocity * delta _pitch = clamp(_pitch, pitchMin, pitchMax) var pivot:Vector3 = targetNode.global_transform.origin + pivotOffset var yawRad:float = deg_to_rad(_yaw) var pitchRad:float = deg_to_rad(_pitch) var dir:Vector3 = Vector3( sin(yawRad) * cos(pitchRad), sin(pitchRad), cos(yawRad) * cos(pitchRad) ) var finalPos:Vector3 = _resolvePosition(pivot, dir) global_transform.origin = finalPos look_at(pivot, Vector3.UP) # Lerp _yaw/_pitch toward the actual constrained position so input resumes # smoothly, and small per-frame raycast variations don't oscillate. var actualDir:Vector3 = (finalPos - pivot).normalized() var targetPitch:float = rad_to_deg(asin(clamp(actualDir.y, -1.0, 1.0))) var targetYaw:float = rad_to_deg(atan2(actualDir.x, actualDir.z)) var feedbackRate:float = minf(120.0 * delta, 1.0) _pitch = lerpf(_pitch, targetPitch, feedbackRate) var feedbackYawDiff:float = fposmod(targetYaw - _yaw + 180.0, 360.0) - 180.0 _yaw += lerpf(0.0, feedbackYawDiff, feedbackRate) func _resolvePosition(pivot:Vector3, dir:Vector3) -> Vector3: var desired:Vector3 = pivot + dir * distance var space:PhysicsDirectSpaceState3D = get_world_3d().direct_space_state var query:PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(pivot, desired) query.collision_mask = collisionMask var hit:Dictionary = space.intersect_ray(query) if hit.is_empty(): return desired var hitNormal:Vector3 = hit["normal"] var hitDist:float = (hit["position"] - pivot).length() - COLLISION_MARGIN # Sphere-plane circle intersection: find the closest valid point that # satisfies both minDistance from pivot and stays clear of terrain. var targetDist:float = maxf(hitDist, minDistance) var d:float = hitNormal.dot(pivot - hit["position"]) - COLLISION_MARGIN var rSq:float = targetDist * targetDist - d * d if rSq <= 0.0: return pivot - hitNormal * d var circleCenter:Vector3 = pivot - hitNormal * d var circleRadius:float = sqrt(rSq) var toDesired:Vector3 = desired - circleCenter toDesired -= hitNormal * hitNormal.dot(toDesired) if toDesired.length_squared() < 0.0001: return circleCenter return circleCenter + toDesired.normalized() * circleRadius