Files
Dawn-Godot/overworld/camera/OverworldCamera.gd
T

203 lines
7.7 KiB
GDScript

class_name OverworldCamera extends Camera3D
enum CameraMode { FREE, CENTERED, MANUAL_CENTER }
# 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 var manualCenterMultiplier:float = 10.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 _canOrbit() -> bool:
return not UI.activeConversation
func _input(event:InputEvent) -> void:
if not _canOrbit():
if _rightMouseHeld:
_rightMouseHeld = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
return
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 = Vector2.ZERO
var mouseActive:bool = false
if _canOrbit():
orbitInput = Input.get_vector(
"camera_orbit_left", "camera_orbit_right",
"camera_orbit_up", "camera_orbit_down"
)
mouseActive = _mouseDelta.length_squared() > 0.0
var controllerActive:bool = orbitInput.length() > 0.01
# 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 MANUAL_CENTER immediately
if _canOrbit() and Input.is_action_just_pressed("center_camera"):
_mode = CameraMode.MANUAL_CENTER
# 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 or _mode == CameraMode.MANUAL_CENTER:
var centerBody := targetNode as CharacterBody3D
var vel3d:Vector3 = Vector3.ZERO if centerBody == null else centerBody.velocity
var behindYaw:float = 0.0
var hasTarget:bool = false
if vel3d.length_squared() > 0.1:
behindYaw = rad_to_deg(atan2(-vel3d.x, -vel3d.z))
hasTarget = true
elif _mode == CameraMode.MANUAL_CENTER and targetNode != null:
behindYaw = rad_to_deg(targetNode.rotation.y)
hasTarget = true
if hasTarget:
var centerYawDiff:float = fposmod(behindYaw - _yaw + 180.0, 360.0) - 180.0
if _mode == CameraMode.MANUAL_CENTER and abs(centerYawDiff) < centeredMaxYawDiff * 0.05:
_mode = CameraMode.FREE
var totalAngle:float = abs(centerYawDiff) + abs(centeredPitch - _pitch)
var speedMult:float = manualCenterMultiplier if _mode == CameraMode.MANUAL_CENTER else 1.0
var dynamicRate:float = minf(centeredFollowRate * (1.0 + totalAngle / 90.0), centeredMaxFollowRate) * speedMult
if abs(centerYawDiff) <= centeredMaxYawDiff:
_centerVelocity = move_toward(_centerVelocity, centerYawDiff * dynamicRate, centeredAcceleration * speedMult * 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