From 3e40d9f0536c14e0bd05a0ee246811554884a93e Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Thu, 11 Jun 2026 22:01:36 -0500 Subject: [PATCH] Camera feeling great --- .claude/docs/overworld.md | 22 +++++--- overworld/camera/OverworldCamera.gd | 80 +++++++++++++++++------------ 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/.claude/docs/overworld.md b/.claude/docs/overworld.md index cbe1289..106dec7 100644 --- a/.claude/docs/overworld.md +++ b/.claude/docs/overworld.md @@ -73,18 +73,28 @@ Camera-relative direction is derived from the active `Camera3D`'s basis — the ## 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 | |---|---|---| | `targetNode:Node3D` | — | Node to orbit (assign Player in scene) | | `pivotOffset:Vector3` | `(0, 1.2, 0)` | Orbit point above entity origin | -| `distance:float` | `10.0` | Orbit radius | -| `pitchMin/Max:float` | `-10° / 70°` | Vertical clamp | -| `orbitSensitivity:float` | `120.0` | Degrees/sec at full input | -| `collisionMask:int` | — | Layers the camera avoids (terrain = layer 1) | +| `distance:float` | `7.0` | Orbit radius | +| `minDistance:float` | `2.0` | Minimum camera distance (collision won't push closer) | +| `pitchMin/Max:float` | `-80° / 70°` | Vertical clamp | +| `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 diff --git a/overworld/camera/OverworldCamera.gd b/overworld/camera/OverworldCamera.gd index 7417ec2..ced2127 100644 --- a/overworld/camera/OverworldCamera.gd +++ b/overworld/camera/OverworldCamera.gd @@ -1,5 +1,7 @@ class_name OverworldCamera extends Camera3D +enum CameraMode { FREE, CENTERED } + const COLLISION_MARGIN:float = 0.3 @export_category("Target") @@ -18,17 +20,19 @@ const COLLISION_MARGIN:float = 0.3 @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 var centeredDelay:float = 2.0 +@export var centeredFollowRate:float = 0.75 +@export var centeredMaxFollowRate:float = 1.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 _idleTimer:float = 0.0 +var _freeTimer:float = 0.0 var _mouseDelta:Vector2 = Vector2.ZERO var _rightMouseHeld:bool = false @@ -51,8 +55,15 @@ func _process(delta:float) -> void: "camera_orbit_up", "camera_orbit_down" ) - # Controller input with speed ramp; instant stop on release 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: _camVelocity = _camVelocity.move_toward( orbitInput * orbitSensitivity * SETTINGS.cameraSpeedController, orbitAcceleration * delta @@ -63,33 +74,41 @@ func _process(delta:float) -> void: _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: + # 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: - _idleTimer += delta + _freeTimer += delta + if _freeTimer >= centeredDelay: + _mode = CameraMode.CENTERED + _freeTimer = 0.0 else: - _idleTimer = 0.0 + _freeTimer = 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) + # In CENTERED mode, smoothly slerp toward behind the player while they move; + # 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( + targetNode.global_transform.basis.z.x, + targetNode.global_transform.basis.z.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) + var t:float = minf(dynamicRate * delta, 1.0) + _pitch = lerpf(_pitch, centeredPitch, t) + _yaw += centerYawDiff * t _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 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) + var feedbackYawDiff:float = fposmod(targetYaw - _yaw + 180.0, 360.0) - 180.0 + _yaw += lerpf(0.0, feedbackYawDiff, 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 @@ -130,26 +148,20 @@ func _resolvePosition(pivot:Vector3, dir:Vector3) -> Vector3: 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. + # 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: - # 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