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