## FpsCamera — first-person camera view effects for tactical FPS. ## ## Manages view bobbing (head bob from movement), weapon sway, and FOV kick. ## The parent FPSCharacterController handles mouse look (yaw/pitch). ## ## Place as a Camera3D child of FPSCharacterController. class_name FpsCamera extends Camera3D # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- ## Enable view bobbing (head bob when walking/running). @export var view_bob_enabled: bool = true ## How much the camera bobs horizontally (units). @export_range(0.0, 0.5) var bob_amplitude_h: float = 0.04 ## How much the camera bobs vertically. @export_range(0.0, 0.5) var bob_amplitude_v: float = 0.04 ## Frequency multiplier for bobbing (higher = faster). @export_range(0.5, 5.0) var bob_frequency: float = 2.5 ## Sprint bob multiplier (more aggressive). @export_range(1.0, 3.0) var bob_sprint_mult: float = 1.6 ## Crouch bob multiplier (less movement). @export_range(0.1, 1.0) var bob_crouch_mult: float = 0.4 ## FOV kick on sprint (tactical FOV increase). @export_range(0.0, 10.0) var sprint_fov_kick: float = 3.0 ## FOV kick when firing weapon. @export_range(-5.0, 10.0) var shoot_fov_kick: float = 0.5 ## How fast FOV recovers. @export var fov_recovery_speed: float = 6.0 ## Enable weapon sway (subtle rotation from movement). @export var weapon_sway_enabled: bool = true ## Maximum weapon sway rotation (degrees). @export_range(0.0, 5.0) var sway_max_rotation: float = 1.5 ## Sway responsiveness (higher = snappier). @export_range(1.0, 20.0) var sway_response: float = 8.0 # --------------------------------------------------------------------------- # Internal # --------------------------------------------------------------------------- ## Reference to parent controller (duck-typed — uses has_method instead of class_name). var _controller: Node = null ## Bob time accumulator. var _bob_time: float = 0.0 ## Current bob offset. var _bob_offset: Vector2 = Vector2.ZERO ## Current weapon node (child named "Weapon" or first MeshInstance3D). var _weapon: Node3D = null ## Weapon rest position (local transform when no sway). var _weapon_rest: Transform3D ## FOV state. var _base_fov: float = 75.0 var _current_fov_offset: float = 0.0 ## Sway state (used as both current value and interpolation target). var _sway_current: Vector2 = Vector2.ZERO var _sway_target: Vector2 = Vector2.ZERO ## Base eye Y set by parent controller's crouch logic (for additive bob). var _base_eye_y: float = 0.0 # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: _controller = get_parent() if _controller == null or not _controller.has_method(&"is_sprinting"): push_warning("FpsCamera: Parent has no is_sprinting() — view features disabled.") _base_fov = fov # Find weapon node (for sway) for child in get_children(): if child is Node3D: _weapon = child _weapon_rest = child.transform break func _process(delta: float) -> void: if _controller == null: return # Capture the eye height set by parent controller's crouch logic # before we apply view bob offsets (which must be additive). _base_eye_y = position.y # FOV management _update_fov(delta) # View bobbing if view_bob_enabled: _update_bob(delta) # Weapon sway if weapon_sway_enabled and _weapon: _update_sway(delta) # --------------------------------------------------------------------------- # FOV # --------------------------------------------------------------------------- func _update_fov(delta: float) -> void: var target_offset: float = 0.0 # Sprint FOV kick — sustained while sprinting (not just on transition) if _controller.is_sprinting(): target_offset = sprint_fov_kick # Shoot FOV kick is triggered externally via trigger_shoot_fov() # and decays toward the sprint/walk target naturally via move_toward. _current_fov_offset = move_toward(_current_fov_offset, target_offset, fov_recovery_speed * delta) fov = _base_fov + _current_fov_offset # --------------------------------------------------------------------------- # View bobbing # --------------------------------------------------------------------------- func _update_bob(delta: float) -> void: var crouch_amount: float = _controller.get_crouch_amount() var sprinting: bool = _controller.is_sprinting() # Calculate bob speed multiplier from controller state var speed_mult: float = 1.0 if sprinting: speed_mult = bob_sprint_mult elif crouch_amount > 0.0: speed_mult = lerpf(1.0, bob_crouch_mult, crouch_amount) if speed_mult < 0.01: # Stationary — reset to zero _bob_offset = _bob_offset.move_toward(Vector2.ZERO, delta * 10.0) else: _bob_time += delta * bob_frequency * speed_mult _bob_offset = Vector2( sin(_bob_time * 2.0) * bob_amplitude_h * speed_mult, sin(_bob_time) * bob_amplitude_v * speed_mult ) # Apply bob to camera position (relative to parent's eye offset) position.x = _bob_offset.x # Bob is additive on Y so it does NOT overwrite the crouch eye height # set by the parent FPSCharacterController._update_crouch(). position.y = _base_eye_y + _bob_offset.y # --------------------------------------------------------------------------- # Weapon sway # --------------------------------------------------------------------------- func _update_sway(delta: float) -> void: # Sway follows mouse movement indirectly via controller yaw/pitch changes # Use velocity for physics-based sway: recentre over time var vel := _controller.velocity as Vector3 var sway_h: float = clamp(vel.x * 0.003, -sway_max_rotation, sway_max_rotation) var sway_v: float = clamp(vel.z * 0.003, -sway_max_rotation, sway_max_rotation) # Add a tiny amount from pitch/yaw delta (not mouse, from movement direction change) _sway_target = Vector2( move_toward(_sway_target.x, sway_h, sway_response * delta), move_toward(_sway_target.y, sway_v, sway_response * delta) ) # Apply to weapon node as small rotation offsets if _weapon: var target := Transform3D( Basis.from_euler(Vector3( deg_to_rad(_sway_target.y), deg_to_rad(_sway_target.x), 0.0 )), _weapon_rest.origin ) _weapon.transform = _weapon.transform.interpolate_with(target, sway_response * delta * 0.5) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- ## Trigger a shoot FOV kick. Call from weapon script. func trigger_shoot_fov() -> void: _current_fov_offset += shoot_fov_kick ## Reset view to resting state (for respawn). func reset_view() -> void: _bob_time = 0.0 _bob_offset = Vector2.ZERO _current_fov_offset = 0.0 _sway_current = Vector2.ZERO _sway_target = Vector2.ZERO fov = _base_fov if _weapon: _weapon.transform = _weapon_rest ## Get the current bob offset for external effects. func get_bob_offset() -> Vector2: return _bob_offset