156 lines
5.8 KiB
GDScript
156 lines
5.8 KiB
GDScript
class_name OverworldCamera extends Camera3D
|
|
|
|
const COLLISION_MARGIN:float = 0.3
|
|
|
|
@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.0
|
|
@export var orbitSensitivity:float = 144.0
|
|
@export var orbitAcceleration:float = 600.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 autoReturnDelay:float = 2.0
|
|
@export var autoReturnSpeed:float = 90.0
|
|
@export var autoReturnPitch:float = 30.0
|
|
|
|
@export_category("Collision")
|
|
@export_flags_3d_physics var collisionMask:int = 1
|
|
|
|
var _yaw:float = 0.0
|
|
var _pitch:float = 30.0
|
|
var _camVelocity:Vector2 = Vector2.ZERO
|
|
var _idleTimer: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"
|
|
)
|
|
|
|
# Controller input with speed ramp; instant stop on release
|
|
var controllerActive:bool = orbitInput.length() > 0.01
|
|
if controllerActive:
|
|
_camVelocity = _camVelocity.move_toward(
|
|
orbitInput * orbitSensitivity * SETTINGS.cameraSpeedController, orbitAcceleration * delta
|
|
)
|
|
_yaw += _camVelocity.x * delta * xMult
|
|
_pitch += _camVelocity.y * delta * yMult
|
|
else:
|
|
_camVelocity = Vector2.ZERO
|
|
|
|
# Right-click mouse drag
|
|
var mouseActive:bool = _mouseDelta.length_squared() > 0.0
|
|
if mouseActive:
|
|
_yaw += _mouseDelta.x * mouseSensitivity * SETTINGS.cameraSpeedMouse * xMult
|
|
_pitch += _mouseDelta.y * mouseSensitivity * SETTINGS.cameraSpeedMouse * yMult
|
|
_mouseDelta = Vector2.ZERO
|
|
|
|
# Auto-center behind player: only when player is moving and camera has been idle
|
|
var centerPressed:bool = Input.is_action_just_pressed("center_camera")
|
|
if centerPressed:
|
|
_idleTimer = autoReturnDelay
|
|
elif controllerActive or mouseActive:
|
|
_idleTimer = 0.0
|
|
else:
|
|
var playerBody := targetNode as CharacterBody3D
|
|
if playerBody != null and playerBody.velocity.length_squared() > 0.1:
|
|
_idleTimer += delta
|
|
else:
|
|
_idleTimer = 0.0
|
|
|
|
if _idleTimer >= autoReturnDelay:
|
|
_pitch = move_toward(_pitch, autoReturnPitch, autoReturnSpeed * delta)
|
|
var behindYaw:float = rad_to_deg(atan2(
|
|
targetNode.global_transform.basis.z.x,
|
|
targetNode.global_transform.basis.z.z
|
|
))
|
|
var yawDiff:float = fposmod(behindYaw - _yaw + 180.0, 360.0) - 180.0
|
|
_yaw += move_toward(0.0, yawDiff, autoReturnSpeed * 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(20.0 * delta, 1.0)
|
|
_pitch = lerpf(_pitch, targetPitch, feedbackRate)
|
|
var yawDiff:float = fposmod(targetYaw - _yaw + 180.0, 360.0) - 180.0
|
|
_yaw += lerpf(0.0, yawDiff, feedbackRate)
|
|
|
|
# Returns the camera world position, respecting both minDistance and terrain.
|
|
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
|
|
|
|
# Find the closest point on the circle formed by intersecting:
|
|
# • a sphere of radius max(hitDist, minDistance) centred on pivot
|
|
# • the terrain surface plane (inset by COLLISION_MARGIN along its normal)
|
|
# Using max() makes both cases meet exactly at hitDist == minDistance,
|
|
# eliminating the branch discontinuity that causes jitter at the boundary.
|
|
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:
|
|
# Pivot is itself nearly on the terrain — sit at the tangent point
|
|
return pivot - hitNormal * d
|
|
|
|
var circleCenter:Vector3 = pivot - hitNormal * d
|
|
var circleRadius:float = sqrt(rSq)
|
|
|
|
# Project desired onto the terrain plane to get the on-plane direction
|
|
var toDesired:Vector3 = desired - circleCenter
|
|
toDesired -= hitNormal * hitNormal.dot(toDesired)
|
|
if toDesired.length_squared() < 0.0001:
|
|
# Desired is directly along the normal — any circle point is equally good
|
|
return circleCenter
|
|
|
|
return circleCenter + toDesired.normalized() * circleRadius
|