- 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]>
218 lines
8.8 KiB
GDScript
218 lines
8.8 KiB
GDScript
extends Node3D
|
|
class_name FPSCameraRig
|
|
|
|
## FPS camera rig — mouse look, head bob, FOV kick, wall-run tilt, plus
|
|
## movement-feel feedback driven by MovementStateMachine events:
|
|
## - landing dip scaled by impact speed
|
|
## - pitch impulse (vault kick) with spring decay
|
|
## - smooth crouch/slide eye height (follows the capsule's smoothed height)
|
|
## - slide roll tilt and dash FOV punch
|
|
## Attach as child of the CharacterBody3D player. Camera3D is a child of this node.
|
|
|
|
@export var sensitivity: float = 0.002
|
|
@export var pitch_limit: float = 89.0
|
|
|
|
var params: MovementParams
|
|
var camera: Camera3D
|
|
var _bob_timer: float = 0.0
|
|
var _target_tilt: float = 0.0
|
|
var _current_tilt: float = 0.0
|
|
var _target_fov: float = 90.0
|
|
var _weapon_fov_override: float = -1.0
|
|
var _step_offset_y: float = 0.0
|
|
|
|
# Movement-feel feedback state
|
|
var _base_eye_y: float = 0.7
|
|
var _land_dip: float = 0.0 # current downward dip (springs back to 0)
|
|
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)
|
|
camera = get_node_or_null("Camera3D")
|
|
_base_eye_y = position.y
|
|
if camera and params:
|
|
camera.fov = SettingsManager.world_fov
|
|
_target_fov = SettingsManager.world_fov
|
|
# Explicitly enable callbacks — set_script() at runtime doesn't auto-register them
|
|
set_process_input(true)
|
|
set_process(true)
|
|
# The state machine may be added after us; hook events once the tree settles.
|
|
call_deferred("_connect_machine")
|
|
|
|
|
|
func _connect_machine() -> void:
|
|
var player := get_parent()
|
|
if not player:
|
|
return
|
|
var sm = player.get_node_or_null("MovementStateMachine")
|
|
if sm and sm is MovementStateMachine:
|
|
_machine = sm
|
|
if not sm.movement_event.is_connected(_on_movement_event):
|
|
sm.movement_event.connect(_on_movement_event)
|
|
|
|
|
|
func _on_movement_event(ev: String, data: Dictionary) -> void:
|
|
match ev:
|
|
"land":
|
|
var fall_speed: float = data.get("fall_speed", 0.0)
|
|
var scale_p: float = params.land_dip_scale if params else 0.012
|
|
var max_p: float = params.land_dip_max if params else 0.25
|
|
_land_dip = clampf(fall_speed * scale_p, 0.0, max_p)
|
|
"dash":
|
|
_dash_fov_kick = 15.0
|
|
"vault":
|
|
pass # pitch impulse is applied via add_pitch_impulse by the machine
|
|
|
|
|
|
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
|
|
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:
|
|
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
|
else:
|
|
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
|
|
|
|
var player: CharacterBody3D = get_parent() as CharacterBody3D
|
|
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()
|
|
|
|
var state := ""
|
|
if _machine:
|
|
state = _machine.current_state
|
|
var is_sliding := state == "slide"
|
|
|
|
# ── FOV kick ──────────────────────────────────────────────────────────
|
|
_target_fov = SettingsManager.world_fov
|
|
var hspeed := Vector2(player.velocity.x, player.velocity.z).length()
|
|
|
|
# Smoothly scale FOV based on speed above walking speed
|
|
var speed_factor = clampf((hspeed - params.walk_speed) / (params.walk_speed * 0.5), 0.0, 1.0)
|
|
_target_fov = lerpf(SettingsManager.world_fov, SettingsManager.world_fov + 20.0, speed_factor)
|
|
|
|
# Dash punch decays on top
|
|
_dash_fov_kick = lerpf(_dash_fov_kick, 0.0, 1.0 - exp(-6.0 * delta))
|
|
_target_fov += _dash_fov_kick
|
|
|
|
if _weapon_fov_override > 0.0:
|
|
_target_fov = _weapon_fov_override
|
|
|
|
camera.fov = lerpf(camera.fov, _target_fov, 1.0 - exp(-params.fov_lerp_speed * delta))
|
|
|
|
# ── Crouch / slide eye height (follows the smoothed capsule) ──────────
|
|
var crouch_factor := 0.0
|
|
if _machine:
|
|
crouch_factor = _machine.get_crouch_factor()
|
|
var eye_drop: float = _machine.original_capsule_height * 0.5 if _machine else 0.9
|
|
var target_eye_y: float = _base_eye_y - crouch_factor * eye_drop
|
|
position.y = lerpf(position.y, target_eye_y, 1.0 - exp(-12.0 * delta))
|
|
|
|
# ── Head bob ──────────────────────────────────────────────────────────
|
|
if player.is_on_floor() and hspeed > 1.0 and not is_sliding:
|
|
_bob_timer += delta * params.head_bob_frequency * (hspeed / params.walk_speed)
|
|
var bob_y := sin(_bob_timer) * params.head_bob_amplitude
|
|
var bob_x := cos(_bob_timer * 0.5) * params.head_bob_amplitude * 0.5
|
|
camera.position.y = lerp(camera.position.y, bob_y, 0.3)
|
|
camera.position.x = lerp(camera.position.x, bob_x, 0.3)
|
|
else:
|
|
_bob_timer = 0.0
|
|
camera.position.y = lerp(camera.position.y, 0.0, 0.15)
|
|
camera.position.x = lerp(camera.position.x, 0.0, 0.15)
|
|
|
|
# ── Landing dip (springs back up) ─────────────────────────────────────
|
|
_land_dip = lerpf(_land_dip, 0.0, 1.0 - exp(-8.0 * delta))
|
|
camera.position.y -= _land_dip
|
|
|
|
# ── Step Smoothing ─────────────────────────────────────────────────────
|
|
_step_offset_y = lerpf(_step_offset_y, 0.0, 15.0 * delta)
|
|
camera.position.y += _step_offset_y
|
|
|
|
# ── Pitch impulse (vault kick etc.), spring-decayed ───────────────────
|
|
_pitch_impulse = lerpf(_pitch_impulse, 0.0, 1.0 - exp(-7.0 * delta))
|
|
camera.rotation.x = _pitch_impulse
|
|
|
|
# ── Tilt: wall-run lean + slide roll ──────────────────────────────────
|
|
var tilt_target := _target_tilt
|
|
if is_sliding:
|
|
# Subtle roll in the slide's steering direction
|
|
var slide_roll: float = params.slide_tilt_angle
|
|
tilt_target += -_machine.input_dir.x * slide_roll if _machine else 0.0
|
|
_current_tilt = lerpf(_current_tilt, tilt_target, 1.0 - exp(-params.wall_run_tilt_speed * delta))
|
|
camera.rotation.z = deg_to_rad(_current_tilt)
|
|
|
|
## Called by the movement system to set wall-run tilt direction.
|
|
## side: -1.0 (left wall), 1.0 (right wall), 0.0 (no tilt)
|
|
func set_wall_tilt(side: float) -> void:
|
|
if params:
|
|
_target_tilt = side * params.wall_run_tilt_angle
|
|
else:
|
|
_target_tilt = side * 12.0
|
|
|
|
func set_weapon_fov(fov: float) -> void:
|
|
_weapon_fov_override = fov
|
|
|
|
|
|
## Called to reset tilt (e.g., on landing).
|
|
func clear_wall_tilt() -> void:
|
|
_target_tilt = 0.0
|
|
|
|
## Kick the camera pitch (degrees); decays back smoothly. Used for vaults.
|
|
func add_pitch_impulse(degrees: float) -> void:
|
|
_pitch_impulse += deg_to_rad(degrees)
|
|
|
|
## Adds an offset to the camera so that when the player physics body snaps up a step,
|
|
## the camera interpolates smoothly instead of snapping.
|
|
func add_step_offset(offset_y: float) -> void:
|
|
_step_offset_y += offset_y
|