feat: Fortnite-style OTS camera + Alt free-look orbit in third person

- Third-person camera chain is now HeadPivot > OrbitPivot > ShoulderOffset
  (0.55m right, 0.12m up) > SpringArm(2.6m, 14 deg) — the character sits
  slightly left of screen centre over-the-shoulder instead of dead centre
  behind. Aim is unchanged (still the first-person camera).
- Hold Alt in third person to orbit the camera freely around the character
  with the mouse (yaw wraps, pitch clamped -69..+29 deg); the character
  keeps facing and aiming where it was. Release Alt and the camera springs
  back behind the shoulder.
- debug/orbit_capture.gd: screenshots the OTS framing, a driven orbit, and
  the spring-back for visual regression.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-19 23:59:44 -04:00
co-authored by Claude Fable 5
parent b0f26e6dde
commit 87b8ae70df
4 changed files with 114 additions and 9 deletions
+37 -4
View File
@@ -28,6 +28,14 @@ var _pitch_impulse: float = 0.0 # extra camera pitch in radians, decays
var _dash_fov_kick: float = 0.0 # extra FOV from dashing, decays
var _machine: MovementStateMachine = null
# Third-person free-look: hold Alt to orbit the camera around the character
# without turning the character or aim; release and it springs back behind.
var _orbit_yaw: float = 0.0
var _orbit_pitch: float = 0.0
const ORBIT_PITCH_MIN := -1.2 # radians (looking down from above)
const ORBIT_PITCH_MAX := 0.5 # radians (looking up from below)
const ORBIT_RETURN_SPEED := 10.0
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
@@ -71,10 +79,17 @@ func _input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
var is_ads = camera and camera.fov < SettingsManager.world_fov - 5.0
var sens = SettingsManager.ads_sensitivity if is_ads else SettingsManager.mouse_sensitivity
# Yaw: rotate the player (parent)
get_parent().rotate_y(-event.relative.x * sens)
# Pitch: rotate this pivot
rotation.x = clampf(rotation.x - event.relative.y * sens, deg_to_rad(-pitch_limit), deg_to_rad(pitch_limit))
if _free_looking():
# Alt held in third person: orbit the camera around the character;
# the character keeps facing (and aiming) where it was.
_orbit_yaw = wrapf(_orbit_yaw - event.relative.x * sens, -PI, PI)
_orbit_pitch = clampf(_orbit_pitch - event.relative.y * sens,
ORBIT_PITCH_MIN, ORBIT_PITCH_MAX)
else:
# Yaw: rotate the player (parent)
get_parent().rotate_y(-event.relative.x * sens)
# Pitch: rotate this pivot
rotation.x = clampf(rotation.x - event.relative.y * sens, deg_to_rad(-pitch_limit), deg_to_rad(pitch_limit))
# Toggle mouse capture with Escape
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
@@ -84,6 +99,14 @@ func _input(event: InputEvent) -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
## True while the player is holding Alt to look around in third person.
func _free_looking() -> bool:
var p := get_parent()
return p != null and "third_person" in p and p.third_person \
and Input.is_key_pressed(KEY_ALT) \
and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED
func _process(delta: float) -> void:
if not camera or not params:
return
@@ -92,6 +115,16 @@ func _process(delta: float) -> void:
if not player:
return
# ── Third-person free-look orbit ──────────────────────────────────────
var orbit := get_node_or_null("OrbitPivot")
if orbit:
if not _free_looking():
# Spring back behind the character once Alt is released.
var t := 1.0 - exp(-ORBIT_RETURN_SPEED * delta)
_orbit_yaw = lerpf(_orbit_yaw, 0.0, t)
_orbit_pitch = lerpf(_orbit_pitch, 0.0, t)
orbit.rotation = Vector3(_orbit_pitch, _orbit_yaw, 0.0)
if not _machine:
_connect_machine()