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 _dash_fov_kick: float = 0.0 # extra FOV from dashing, decays
var _machine: MovementStateMachine = null 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: func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) 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: 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 is_ads = camera and camera.fov < SettingsManager.world_fov - 5.0
var sens = SettingsManager.ads_sensitivity if is_ads else SettingsManager.mouse_sensitivity var sens = SettingsManager.ads_sensitivity if is_ads else SettingsManager.mouse_sensitivity
# Yaw: rotate the player (parent) if _free_looking():
get_parent().rotate_y(-event.relative.x * sens) # Alt held in third person: orbit the camera around the character;
# Pitch: rotate this pivot # the character keeps facing (and aiming) where it was.
rotation.x = clampf(rotation.x - event.relative.y * sens, deg_to_rad(-pitch_limit), deg_to_rad(pitch_limit)) _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 # Toggle mouse capture with Escape
if event is InputEventKey and event.pressed and event.keycode == KEY_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) 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: func _process(delta: float) -> void:
if not camera or not params: if not camera or not params:
return return
@@ -92,6 +115,16 @@ func _process(delta: float) -> void:
if not player: if not player:
return 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: if not _machine:
_connect_machine() _connect_machine()
+59
View File
@@ -0,0 +1,59 @@
extends SceneTree
## Dev tool: screenshot the third-person over-the-shoulder framing, the Alt
## free-look orbit, and the spring-back. Run:
## godot --path . --windowed --resolution 1280x720 -s res://debug/orbit_capture.gd -- <out_dir>
var _frames := 0
var _out_dir := "."
var _player: Node = null
var _rig: Node = null
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _frames == 40:
var sm = root.get_node_or_null("SkinManager")
if sm:
sm.set_active_skin("taila")
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
elif _frames == 160:
for p in root.find_children("*", "CharacterBody3D", true, false):
if p.has_method("set_third_person") and p.is_multiplayer_authority():
_player = p
break
if not _player:
printerr("orbit_capture: no player")
return true
_rig = _player.get_node_or_null("HeadPivot")
_player.set_third_person(true)
elif _frames == 220:
_shot("ots_default") # over-the-shoulder framing, no orbit
elif _frames > 220 and _frames <= 280:
# Simulate holding Alt: force the orbit angles every frame (the
# spring-back fights us since Alt isn't really down, so keep writing).
_rig._orbit_yaw = 1.9
_rig._orbit_pitch = -0.25
elif _frames == 281:
_shot("orbit_side") # camera swung around toward the front
elif _frames == 320:
_shot("sprung_back") # ~0.65s after release: should be behind again
return true
return false
func _shot(tag: String) -> void:
var img := root.get_viewport().get_texture().get_image()
var path := _out_dir + "/" + tag + ".png"
img.save_png(path)
print("orbit_capture: saved ", path)
+2 -1
View File
@@ -98,7 +98,8 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
# Third-person toggle: camera swap + owner model visibility. # Third-person toggle: camera swap + owner model visibility.
if player.has_method("set_third_person"): if player.has_method("set_third_person"):
var tp_cam = player.get_node_or_null("HeadPivot/ThirdPersonBoom/ThirdPersonCamera") var tp_cam = player.get_node_or_null(
"HeadPivot/OrbitPivot/ShoulderOffset/ThirdPersonBoom/ThirdPersonCamera")
_check(tp_cam != null, "third-person camera boom created") _check(tp_cam != null, "third-person camera boom created")
player.set_third_person(true) player.set_third_person(true)
await process_frame await process_frame
+16 -4
View File
@@ -168,15 +168,27 @@ func _ready() -> void:
func _setup_third_person_camera() -> void: func _setup_third_person_camera() -> void:
if not head_pivot or not is_instance_valid(camera): if not head_pivot or not is_instance_valid(camera):
return return
# Chain: head_pivot > OrbitPivot (Alt free-look, springs back to zero)
# > ShoulderOffset (camera right of the spine — the character sits
# slightly LEFT of screen centre, Fortnite-style) > SpringArm.
var orbit := Node3D.new()
orbit.name = "OrbitPivot"
head_pivot.add_child(orbit)
var shoulder := Node3D.new()
shoulder.name = "ShoulderOffset"
shoulder.position = Vector3(0.55, 0.12, 0.0)
orbit.add_child(shoulder)
var boom := SpringArm3D.new() var boom := SpringArm3D.new()
boom.name = "ThirdPersonBoom" boom.name = "ThirdPersonBoom"
boom.spring_length = 3.2 boom.spring_length = 2.6
boom.margin = 0.3 boom.margin = 0.3
boom.collision_mask = 1 # environment only boom.collision_mask = 1 # environment only
boom.add_excluded_object(get_rid()) boom.add_excluded_object(get_rid())
# Aim the arm up-and-back from the head so the camera sits behind/above. # Aim the arm up-and-back from the shoulder so the camera sits behind/above.
boom.rotation_degrees = Vector3(20, 0, 0) boom.rotation_degrees = Vector3(14, 0, 0)
head_pivot.add_child(boom) shoulder.add_child(boom)
_tp_camera = Camera3D.new() _tp_camera = Camera3D.new()
_tp_camera.name = "ThirdPersonCamera" _tp_camera.name = "ThirdPersonCamera"