Camera feeling great
This commit is contained in:
@@ -73,18 +73,28 @@ Camera-relative direction is derived from the active `Camera3D`'s basis — the
|
|||||||
|
|
||||||
## Camera
|
## Camera
|
||||||
|
|
||||||
`OverworldCamera` orbits around `targetNode` using yaw/pitch angles driven by `camera_orbit_*` inputs.
|
`OverworldCamera` orbits around `targetNode` using yaw/pitch angles. It operates in two modes: `CameraMode.FREE` (stays where placed) and `CameraMode.CENTERED` (lerps to orbit directly behind the player).
|
||||||
|
|
||||||
| Export | Default | Purpose |
|
| Export | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `targetNode:Node3D` | — | Node to orbit (assign Player in scene) |
|
| `targetNode:Node3D` | — | Node to orbit (assign Player in scene) |
|
||||||
| `pivotOffset:Vector3` | `(0, 1.2, 0)` | Orbit point above entity origin |
|
| `pivotOffset:Vector3` | `(0, 1.2, 0)` | Orbit point above entity origin |
|
||||||
| `distance:float` | `10.0` | Orbit radius |
|
| `distance:float` | `7.0` | Orbit radius |
|
||||||
| `pitchMin/Max:float` | `-10° / 70°` | Vertical clamp |
|
| `minDistance:float` | `2.0` | Minimum camera distance (collision won't push closer) |
|
||||||
| `orbitSensitivity:float` | `120.0` | Degrees/sec at full input |
|
| `pitchMin/Max:float` | `-80° / 70°` | Vertical clamp |
|
||||||
| `collisionMask:int` | — | Layers the camera avoids (terrain = layer 1) |
|
| `orbitSensitivity:float` | `144.0` | Degrees/sec at full controller input |
|
||||||
|
| `orbitAcceleration:float` | `600.0` | Controller ramp speed (deg/s²) |
|
||||||
|
| `mouseSensitivity:float` | `0.3` | Right-click drag sensitivity |
|
||||||
|
| `centeredDelay:float` | `2.0` | Seconds of walking + no camera input before switching to CENTERED |
|
||||||
|
| `centeredFollowRate:float` | `3.0` | Lerp rate in CENTERED mode (higher = faster; 3 ≈ 1s to fully center) |
|
||||||
|
| `centeredPitch:float` | `30.0` | Target pitch in CENTERED mode |
|
||||||
|
| `collisionMask:int` | `1` | Physics layers the camera avoids; entities are on layer 2 and excluded by default |
|
||||||
|
|
||||||
If the ray from pivot to desired camera position hits `collisionMask`, the camera is pulled in to just in front of the hit point (with `COLLISION_MARGIN = 0.3`), clamped to `minDistance`.
|
**Mode transitions:**
|
||||||
|
- FREE → CENTERED: after `centeredDelay` seconds of player movement with no camera input, or immediately via `center_camera` (G / LB)
|
||||||
|
- CENTERED → FREE: any manual camera input (controller stick or right-click drag)
|
||||||
|
|
||||||
|
Collision resolution uses a sphere-plane circle intersection so the camera satisfies both `minDistance` and terrain clearance simultaneously (`COLLISION_MARGIN = 0.3`).
|
||||||
|
|
||||||
## Adding a new map
|
## Adding a new map
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
class_name OverworldCamera extends Camera3D
|
class_name OverworldCamera extends Camera3D
|
||||||
|
|
||||||
|
enum CameraMode { FREE, CENTERED }
|
||||||
|
|
||||||
const COLLISION_MARGIN:float = 0.3
|
const COLLISION_MARGIN:float = 0.3
|
||||||
|
|
||||||
@export_category("Target")
|
@export_category("Target")
|
||||||
@@ -18,17 +20,19 @@ const COLLISION_MARGIN:float = 0.3
|
|||||||
@export var mouseSensitivity:float = 0.3
|
@export var mouseSensitivity:float = 0.3
|
||||||
|
|
||||||
@export_category("Auto-center")
|
@export_category("Auto-center")
|
||||||
@export var autoReturnDelay:float = 2.0
|
@export var centeredDelay:float = 2.0
|
||||||
@export var autoReturnSpeed:float = 90.0
|
@export var centeredFollowRate:float = 0.75
|
||||||
@export var autoReturnPitch:float = 30.0
|
@export var centeredMaxFollowRate:float = 1.0
|
||||||
|
@export var centeredPitch:float = 30.0
|
||||||
|
|
||||||
@export_category("Collision")
|
@export_category("Collision")
|
||||||
@export_flags_3d_physics var collisionMask:int = 1
|
@export_flags_3d_physics var collisionMask:int = 1
|
||||||
|
|
||||||
|
var _mode:CameraMode = CameraMode.CENTERED
|
||||||
var _yaw:float = 0.0
|
var _yaw:float = 0.0
|
||||||
var _pitch:float = 30.0
|
var _pitch:float = 30.0
|
||||||
var _camVelocity:Vector2 = Vector2.ZERO
|
var _camVelocity:Vector2 = Vector2.ZERO
|
||||||
var _idleTimer:float = 0.0
|
var _freeTimer:float = 0.0
|
||||||
var _mouseDelta:Vector2 = Vector2.ZERO
|
var _mouseDelta:Vector2 = Vector2.ZERO
|
||||||
var _rightMouseHeld:bool = false
|
var _rightMouseHeld:bool = false
|
||||||
|
|
||||||
@@ -51,8 +55,15 @@ func _process(delta:float) -> void:
|
|||||||
"camera_orbit_up", "camera_orbit_down"
|
"camera_orbit_up", "camera_orbit_down"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Controller input with speed ramp; instant stop on release
|
|
||||||
var controllerActive:bool = orbitInput.length() > 0.01
|
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 with speed ramp; instant stop on release
|
||||||
if controllerActive:
|
if controllerActive:
|
||||||
_camVelocity = _camVelocity.move_toward(
|
_camVelocity = _camVelocity.move_toward(
|
||||||
orbitInput * orbitSensitivity * SETTINGS.cameraSpeedController, orbitAcceleration * delta
|
orbitInput * orbitSensitivity * SETTINGS.cameraSpeedController, orbitAcceleration * delta
|
||||||
@@ -63,33 +74,41 @@ func _process(delta:float) -> void:
|
|||||||
_camVelocity = Vector2.ZERO
|
_camVelocity = Vector2.ZERO
|
||||||
|
|
||||||
# Right-click mouse drag
|
# Right-click mouse drag
|
||||||
var mouseActive:bool = _mouseDelta.length_squared() > 0.0
|
|
||||||
if mouseActive:
|
if mouseActive:
|
||||||
_yaw += _mouseDelta.x * mouseSensitivity * SETTINGS.cameraSpeedMouse * xMult
|
_yaw += _mouseDelta.x * mouseSensitivity * SETTINGS.cameraSpeedMouse * xMult
|
||||||
_pitch += _mouseDelta.y * mouseSensitivity * SETTINGS.cameraSpeedMouse * yMult
|
_pitch += _mouseDelta.y * mouseSensitivity * SETTINGS.cameraSpeedMouse * yMult
|
||||||
_mouseDelta = Vector2.ZERO
|
_mouseDelta = Vector2.ZERO
|
||||||
|
|
||||||
# Auto-center behind player: only when player is moving and camera has been idle
|
# center_camera input → switch to CENTERED immediately
|
||||||
var centerPressed:bool = Input.is_action_just_pressed("center_camera")
|
if Input.is_action_just_pressed("center_camera"):
|
||||||
if centerPressed:
|
_mode = CameraMode.CENTERED
|
||||||
_idleTimer = autoReturnDelay
|
|
||||||
elif controllerActive or mouseActive:
|
# In FREE mode, accumulate time toward auto-centering while the player is moving
|
||||||
_idleTimer = 0.0
|
if _mode == CameraMode.FREE:
|
||||||
else:
|
|
||||||
var playerBody := targetNode as CharacterBody3D
|
var playerBody := targetNode as CharacterBody3D
|
||||||
if playerBody != null and playerBody.velocity.length_squared() > 0.1:
|
if playerBody != null and playerBody.velocity.length_squared() > 0.1:
|
||||||
_idleTimer += delta
|
_freeTimer += delta
|
||||||
|
if _freeTimer >= centeredDelay:
|
||||||
|
_mode = CameraMode.CENTERED
|
||||||
|
_freeTimer = 0.0
|
||||||
else:
|
else:
|
||||||
_idleTimer = 0.0
|
_freeTimer = 0.0
|
||||||
|
|
||||||
if _idleTimer >= autoReturnDelay:
|
# In CENTERED mode, smoothly slerp toward behind the player while they move;
|
||||||
_pitch = move_toward(_pitch, autoReturnPitch, autoReturnSpeed * delta)
|
# rate scales up with angular distance so large offsets catch up quickly.
|
||||||
|
if _mode == CameraMode.CENTERED:
|
||||||
|
var centerBody := targetNode as CharacterBody3D
|
||||||
|
if centerBody != null and centerBody.velocity.length_squared() > 0.1:
|
||||||
var behindYaw:float = rad_to_deg(atan2(
|
var behindYaw:float = rad_to_deg(atan2(
|
||||||
targetNode.global_transform.basis.z.x,
|
targetNode.global_transform.basis.z.x,
|
||||||
targetNode.global_transform.basis.z.z
|
targetNode.global_transform.basis.z.z
|
||||||
))
|
))
|
||||||
var yawDiff:float = fposmod(behindYaw - _yaw + 180.0, 360.0) - 180.0
|
var centerYawDiff:float = fposmod(behindYaw - _yaw + 180.0, 360.0) - 180.0
|
||||||
_yaw += move_toward(0.0, yawDiff, autoReturnSpeed * delta)
|
var totalAngle:float = abs(centerYawDiff) + abs(centeredPitch - _pitch)
|
||||||
|
var dynamicRate:float = minf(centeredFollowRate * (1.0 + totalAngle / 90.0), centeredMaxFollowRate)
|
||||||
|
var t:float = minf(dynamicRate * delta, 1.0)
|
||||||
|
_pitch = lerpf(_pitch, centeredPitch, t)
|
||||||
|
_yaw += centerYawDiff * t
|
||||||
|
|
||||||
_pitch = clamp(_pitch, pitchMin, pitchMax)
|
_pitch = clamp(_pitch, pitchMin, pitchMax)
|
||||||
|
|
||||||
@@ -113,10 +132,9 @@ func _process(delta:float) -> void:
|
|||||||
var targetYaw:float = rad_to_deg(atan2(actualDir.x, actualDir.z))
|
var targetYaw:float = rad_to_deg(atan2(actualDir.x, actualDir.z))
|
||||||
var feedbackRate:float = minf(20.0 * delta, 1.0)
|
var feedbackRate:float = minf(20.0 * delta, 1.0)
|
||||||
_pitch = lerpf(_pitch, targetPitch, feedbackRate)
|
_pitch = lerpf(_pitch, targetPitch, feedbackRate)
|
||||||
var yawDiff:float = fposmod(targetYaw - _yaw + 180.0, 360.0) - 180.0
|
var feedbackYawDiff:float = fposmod(targetYaw - _yaw + 180.0, 360.0) - 180.0
|
||||||
_yaw += lerpf(0.0, yawDiff, feedbackRate)
|
_yaw += lerpf(0.0, feedbackYawDiff, feedbackRate)
|
||||||
|
|
||||||
# Returns the camera world position, respecting both minDistance and terrain.
|
|
||||||
func _resolvePosition(pivot:Vector3, dir:Vector3) -> Vector3:
|
func _resolvePosition(pivot:Vector3, dir:Vector3) -> Vector3:
|
||||||
var desired:Vector3 = pivot + dir * distance
|
var desired:Vector3 = pivot + dir * distance
|
||||||
var space:PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
|
var space:PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
|
||||||
@@ -130,26 +148,20 @@ func _resolvePosition(pivot:Vector3, dir:Vector3) -> Vector3:
|
|||||||
var hitNormal:Vector3 = hit["normal"]
|
var hitNormal:Vector3 = hit["normal"]
|
||||||
var hitDist:float = (hit["position"] - pivot).length() - COLLISION_MARGIN
|
var hitDist:float = (hit["position"] - pivot).length() - COLLISION_MARGIN
|
||||||
|
|
||||||
# Find the closest point on the circle formed by intersecting:
|
# Sphere-plane circle intersection: find the closest valid point that
|
||||||
# • a sphere of radius max(hitDist, minDistance) centred on pivot
|
# satisfies both minDistance from pivot and stays clear of terrain.
|
||||||
# • 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 targetDist:float = maxf(hitDist, minDistance)
|
||||||
var d:float = hitNormal.dot(pivot - hit["position"]) - COLLISION_MARGIN
|
var d:float = hitNormal.dot(pivot - hit["position"]) - COLLISION_MARGIN
|
||||||
var rSq:float = targetDist * targetDist - d * d
|
var rSq:float = targetDist * targetDist - d * d
|
||||||
if rSq <= 0.0:
|
if rSq <= 0.0:
|
||||||
# Pivot is itself nearly on the terrain — sit at the tangent point
|
|
||||||
return pivot - hitNormal * d
|
return pivot - hitNormal * d
|
||||||
|
|
||||||
var circleCenter:Vector3 = pivot - hitNormal * d
|
var circleCenter:Vector3 = pivot - hitNormal * d
|
||||||
var circleRadius:float = sqrt(rSq)
|
var circleRadius:float = sqrt(rSq)
|
||||||
|
|
||||||
# Project desired onto the terrain plane to get the on-plane direction
|
|
||||||
var toDesired:Vector3 = desired - circleCenter
|
var toDesired:Vector3 = desired - circleCenter
|
||||||
toDesired -= hitNormal * hitNormal.dot(toDesired)
|
toDesired -= hitNormal * hitNormal.dot(toDesired)
|
||||||
if toDesired.length_squared() < 0.0001:
|
if toDesired.length_squared() < 0.0001:
|
||||||
# Desired is directly along the normal — any circle point is equally good
|
|
||||||
return circleCenter
|
return circleCenter
|
||||||
|
|
||||||
return circleCenter + toDesired.normalized() * circleRadius
|
return circleCenter + toDesired.normalized() * circleRadius
|
||||||
|
|||||||
Reference in New Issue
Block a user