FPS Controller

This commit is contained in:
2025-11-13 08:05:46 -05:00
parent c6e4d4f725
commit 14000f0096
177 changed files with 6311 additions and 7 deletions

View File

@@ -0,0 +1,62 @@
class_name CameraController extends Node3D
@export var debug : bool = false
@export_category("References")
@export var player_controller : PlayerController
@export var component_mouse_capture : MouseCaptureComponent
@export_category("Camera Settings")
@export_group("Camera Tilt")
@export_range(-90, -60) var tilt_lower_limit : int = -90
@export_range(60, 90) var tilt_upper_limit : int = 90
@export_group("Crouch Vertical Movement")
@export var crouch_offset : float = 0.0
@export var crouch_speed : float = 3.0
@export_group("Step Smoothing")
@export var step_speed: float = 8.0
# Step smoothing private values
var _target_height: float
var _step_smoothing: bool = false
var offset_height: float
var _rotation : Vector3
const DEFAULT_HEIGHT : float = 0.5
func _ready() -> void:
_rotation = player_controller.rotation
offset_height = DEFAULT_HEIGHT
func _process(delta: float) -> void:
update_camera_rotation(component_mouse_capture._mouse_input)
if _step_smoothing:
_target_height = lerp(_target_height, 0.0, step_speed * delta)
if abs(_target_height) < 0.01:
_target_height = 0.0
_step_smoothing = false
position.y = offset_height + _target_height
func smooth_step(height_change: float):
_target_height -= height_change
_step_smoothing = true
func update_camera_rotation(input: Vector2) -> void:
_rotation.x += input.y
_rotation.y += input.x
_rotation.x = clamp(_rotation.x, deg_to_rad(tilt_lower_limit), deg_to_rad(tilt_upper_limit))
var _player_rotation = Vector3(0.0,_rotation.y,0.0)
var _camera_rotation = Vector3(_rotation.x,0.0,0.0)
transform.basis = Basis.from_euler(_camera_rotation)
player_controller.update_rotation(_player_rotation)
rotation.z = 0.0
func update_camera_height(delta: float, direction: int) -> void:
if position.y >= crouch_offset and position.y <= DEFAULT_HEIGHT:
position.y = clampf(position.y + (crouch_speed * direction) * delta, crouch_offset, DEFAULT_HEIGHT)

View File

@@ -0,0 +1 @@
uid://4ocvqma2qqqy

View File

@@ -0,0 +1,154 @@
class_name CameraEffects extends Camera3D
@export_category("References")
@export var player: PlayerController
@export_category("Effects")
@export var enable_tilt: bool = true
@export var enable_fall_kick: bool = true
@export var enable_damage_kick: bool = true
@export var enable_weapon_kick: bool = true
@export var enable_screen_shake: bool = true
@export var enable_head_bob: bool = true
@export_category("Kick & Recoil")
@export_group("Run Tilt")
@export var run_pitch: float = 0.1 # Degrees
@export var run_roll: float = 0.25 # Degrees
@export var max_pitch: float = 1.0 # Degrees
@export var max_roll: float = 2.5 # Degrees
@export_group("Camera Kick")
@export_subgroup("Fall Kick")
@export var fall_time: float = 0.3
@export_subgroup("Damage Kick")
@export var damage_time: float = 0.3
@export_subgroup("Weapon Kick")
@export var weapon_decay: float = 0.5
@export_subgroup("HeadBob")
@export_range(0.0, 0.1, 0.001) var bob_pitch: float = 0.05
@export_range(0.0, 0.1, 0.001) var bob_roll: float = 0.025
@export_range(0.0, 0.04, 0.001) var bob_up: float = 0.005
@export_range(3.0, 8.0, 0.1) var bob_frequency: float = 6.0
# fall kick private vars
var _fall_value: float = 0.0
var _fall_timer: float = 0.0
# damage kick private vars
var _damage_pitch: float = 0.0
var _damage_roll: float = 0.0
var _damage_timer: float = 0.0
# weapon kick private variable
var _weapon_kick_angle: Vector3 = Vector3.ZERO
# screen shake private variables
var _screen_shake_tween: Tween
# head bob private variables
var _step_timer: float = 0.0
const MIN_SCREEN_SHAKE: float = 0.05
const MAX_SCREEN_SHAKE: float = 0.5
func _process(delta: float) -> void:
calc_view_offset(delta)
func calc_view_offset(delta: float):
assert(player, "player is not attached to camera effects script")
var offset = Vector3.ZERO
var angles = Vector3.ZERO
var velocity = player.velocity
if enable_tilt:
var forward = global_transform.basis.z
var right = global_transform.basis.x
var forward_dot = velocity.dot(forward)
var forward_tilt = clampf(forward_dot * deg_to_rad(run_pitch), deg_to_rad(-max_pitch), deg_to_rad(max_pitch))
var right_dot = velocity.dot(right)
var side_tilt = clampf(right_dot * deg_to_rad(run_roll), deg_to_rad(-max_roll), deg_to_rad(max_roll))
angles.z -= side_tilt
angles.x += forward_tilt
if enable_fall_kick:
_fall_timer -= delta
var fall_ratio = max(0.0, _fall_timer / fall_time)
var fall_kick_amount = fall_ratio * _fall_value
offset.y -= fall_kick_amount
angles.x -= fall_kick_amount
if enable_fall_kick:
_damage_timer -= delta
var damage_ratio = max(0.0, _damage_timer / damage_time)
angles.x += damage_ratio * _damage_pitch
angles.z += damage_ratio * _damage_roll
if enable_weapon_kick:
_weapon_kick_angle = _weapon_kick_angle.move_toward(Vector3.ZERO, weapon_decay * delta)
angles += _weapon_kick_angle
if enable_head_bob:
var speed = Vector2(velocity.x, velocity.z).length()
if speed > 0.1 and player.is_on_floor():
_step_timer += delta * (speed / bob_frequency)
_step_timer = fmod(_step_timer, 1.0)
else:
_step_timer = 0.0
var bob_sin = sin(_step_timer * 2.0 * PI) * 0.5
var pitch_delta = bob_sin * deg_to_rad(bob_pitch) * speed
var roll_delta = bob_sin * deg_to_rad(bob_roll) * speed
var height_delta = bob_sin * delta * bob_up
angles.x -= pitch_delta
angles.z -= roll_delta
offset.y += height_delta
self.position = offset
self.rotation = angles
func add_fall_kick(fall_strength: float):
_fall_value = deg_to_rad(fall_strength)
_fall_timer = fall_time
func add_damage_kick(pitch: float, roll: float, source: Vector3):
var forward = global_transform.basis.z
var right = global_transform.basis.x
var direction = global_position.direction_to(source)
var forward_dot = direction.dot(forward)
var right_dot = direction.dot(right)
_damage_pitch = deg_to_rad(pitch) * forward_dot
_damage_roll = deg_to_rad(roll) * right_dot
_damage_timer = damage_time
func add_weapon_kick(pitch: float, yaw: float, roll: float):
_weapon_kick_angle.x += deg_to_rad(pitch)
_weapon_kick_angle.y += deg_to_rad(randf_range(-yaw, yaw))
_weapon_kick_angle.z += deg_to_rad(randf_range(-roll, roll))
func add_screen_shake(amount: float, seconds: float) -> void:
if _screen_shake_tween:
_screen_shake_tween.kill()
_screen_shake_tween = create_tween()
_screen_shake_tween.tween_method(update_screen_shake.bind(amount), 0.0, 1.0, seconds).set_ease(Tween.EASE_OUT)
func update_screen_shake(alpha: float, amount: float) -> void:
amount = remap(amount, 0.0, 1.0, MIN_SCREEN_SHAKE, MAX_SCREEN_SHAKE)
var current_shake_amount = amount * (1.0 - alpha)
h_offset = randf_range(-current_shake_amount, current_shake_amount)
v_offset = randf_range(-current_shake_amount, current_shake_amount)

View File

@@ -0,0 +1 @@
uid://cpx1w1k6orwyo

View File

@@ -0,0 +1,13 @@
extends RayCast3D
var current_object
func _process(delta: float) -> void:
if is_colliding():
var object = get_collider()
if object == current_object:
return
else:
current_object = object
else:
current_object = null

View File

@@ -0,0 +1 @@
uid://bqeyf8hqbc7xy

View File

@@ -0,0 +1,26 @@
class_name MouseCaptureComponent extends Node
@export var debug : bool = false
@export_category("Mouse Capture Settings")
@export var current_mouse_mode : Input.MouseMode = Input.MOUSE_MODE_CAPTURED
@export var mouse_sensitivity : float = 0.005
var _capture_mouse : bool
var _mouse_input : Vector2
## Captures the relative mouse movement from the center of the screen
func _unhandled_input(event: InputEvent) -> void:
_capture_mouse = event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED
if _capture_mouse:
_mouse_input.x += -event.screen_relative.x * mouse_sensitivity
_mouse_input.y += -event.screen_relative.y * mouse_sensitivity
if debug:
print(_mouse_input)
func _ready() -> void:
Input.mouse_mode = current_mouse_mode
func _process(delta: float) -> void:
_mouse_input = Vector2.ZERO

View File

@@ -0,0 +1 @@
uid://d0xgg3pig4b6i

View File

@@ -0,0 +1,11 @@
class_name PlayerStateMachine extends Node
@export var debug : bool = false
@export_category("References")
@export var player_controller : PlayerController
func _process(delta: float) -> void:
if player_controller:
player_controller.state_chart.set_expression_property("Player Velocity", player_controller.velocity)
player_controller.state_chart.set_expression_property("Player Hitting Head", player_controller.crouch_check.is_colliding())
player_controller.state_chart.set_expression_property("Looking At: ", player_controller.interaction_raycast.current_object)

View File

@@ -0,0 +1 @@
uid://cqrpha4131qgx

View File

@@ -0,0 +1,83 @@
class_name StepHandlerComponet extends Node
@export_category("References")
@export var player: PlayerController
@export_category("Step Settings")
@export var surface_threshold: float = 0.3
@export var step_height: float = 0.3
const FEET_ADJUSTED_HEIGHT = 0.05
const MIN_MOVEMENT_LENGTH: float = 0.1
const MIN_DOT_VALUE: float = 0.5
const MIN_STEP_HEIGHT: float = 0.1
func handle_step_climbing():
for i in player.get_slide_collision_count():
var collision = player.get_slide_collision(i)
if _is_surface_verical(collision):
var measured_height = _measure_step_height(collision)
if measured_height > MIN_STEP_HEIGHT and measured_height <= step_height and _is_valid_step_direction(collision):
player.global_position.y += measured_height
player.velocity = player.previous_velocity
player.camera.smooth_step(measured_height)
func _is_valid_step_direction(collision: KinematicCollision3D):
var collision_normal = collision.get_normal()
var input_dir = player.get_input_direction()
var movement_direction = player.transform.basis * Vector3(input_dir.x, 0, input_dir.y)
if movement_direction.length() > MIN_MOVEMENT_LENGTH:
movement_direction = movement_direction.normalized()
var dot_product = movement_direction.dot(-collision_normal)
return dot_product > MIN_DOT_VALUE
return false
func _measure_step_height(collision: KinematicCollision3D) -> float:
var space_state = player.get_world_3d().direct_space_state
var collision_point = collision.get_position()
var player_feet = _get_player_feet_position()
var player_head_y = player.global_position.y + (player.standing_collision.shape.height / 2)
var ray_start = Vector3(collision_point.x, player_head_y, collision_point.z)
var ray_end = Vector3(collision_point.x, player_feet.y, collision_point.z)
var query = PhysicsRayQueryParameters3D.create(ray_start, ray_end)
query.collision_mask = player.collision_mask
query.exclude = [player.get_rid()]
var result = space_state.intersect_ray(query)
if result:
return result.position.y - player_feet.y
return 0.0
func _is_surface_verical(collision: KinematicCollision3D) -> bool:
var normal = collision.get_normal()
if abs(normal.y) <= surface_threshold:
return true
return _check_collison_surface(collision)
# additional work to validate
func _check_collison_surface(collision: KinematicCollision3D) -> bool:
var space_state = player.get_world_3d().direct_space_state
var collision_point = collision.get_position()
var player_feet: Vector3 = _get_player_feet_position()
collision_point.y = player_feet.y
var query = PhysicsRayQueryParameters3D.create(player_feet, collision_point)
query.collision_mask = player.collision_mask
query.exclude = [player.get_rid()]
var result = space_state.intersect_ray(query)
if result and abs(result.normal.y) <= surface_threshold:
return true
return false
func _get_player_feet_position() -> Vector3:
var feet_pos = player.global_position
feet_pos.y -= player.standing_collision.shape.height / 2
feet_pos.y += FEET_ADJUSTED_HEIGHT
return feet_pos

View File

@@ -0,0 +1 @@
uid://b1us8up0jvwjb

View File

@@ -0,0 +1,98 @@
class_name PlayerController extends CharacterBody3D
@export var debug: bool = false
@export_category("References")
@export var camera: CameraController
@export var camera_effects: CameraEffects
@export var state_chart: StateChart
@export var standing_collision: CollisionShape3D
@export var crouching_collision: CollisionShape3D
@export var crouch_check: ShapeCast3D
@export var interaction_raycast: RayCast3D
@export var step_handler: StepHandlerComponet
@export_category("Movement Settings")
@export_group("Easing")
@export var acceleration: float = 0.2
@export var deceleration: float = 0.5
@export_group("Speed")
@export var default_speed: float = 7.0
@export var sprint_speed: float = 3.0
@export var crouch_speed: float = -5.0
@export_category("Jump Settings")
@export var jump_velocity: float = 5.0
@export var fall_velocity_threashold: float = -5.0
var _input_dir: Vector2 = Vector2.ZERO
var _movement_velocity: Vector3 = Vector3.ZERO
var sprint_modifier: float = 0.0
var crouch_modifier: float = 0.0
var speed: float = 0.0
var current_fall_velocity: float
var previous_velocity: Vector3
func _physics_process(delta: float) -> void:
previous_velocity = self.velocity
if not is_on_floor():
velocity += get_gravity() * delta
var speed_modifier = sprint_modifier + crouch_modifier
speed = default_speed + speed_modifier
_input_dir = Input.get_vector("move_left", "move_right", "move_forward", "move_backward")
var current_velocity = Vector2(_movement_velocity.x, _movement_velocity.z)
var direction = (transform.basis * Vector3(_input_dir.x, 0, _input_dir.y)).normalized()
if direction:
current_velocity = lerp(current_velocity, Vector2(direction.x, direction.z) * speed, acceleration)
else:
current_velocity = current_velocity.move_toward(Vector2.ZERO, deceleration)
_movement_velocity = Vector3(current_velocity.x, velocity.y, current_velocity.y)
velocity = _movement_velocity
move_and_slide()
if is_on_floor():
step_handler.handle_step_climbing()
func get_input_direction() -> Vector2:
return _input_dir
func update_rotation(rotation_input) -> void:
global_transform.basis = Basis.from_euler(rotation_input)
func sprint() -> void:
sprint_modifier = sprint_speed
func walk() -> void:
sprint_modifier = 0.0
func stand() -> void:
crouch_modifier = 0.0
standing_collision.disabled = false
crouching_collision.disabled = true
func crouch() -> void:
crouch_modifier = crouch_speed
standing_collision.disabled = true
crouching_collision.disabled = false
func jump() -> void:
velocity.y += jump_velocity
func check_fall_speed() -> bool:
if current_fall_velocity < fall_velocity_threashold:
current_fall_velocity = 0.0
return true
current_fall_velocity = 0.0
return false

View File

@@ -0,0 +1 @@
uid://caf5bdhu67k5q

View File

@@ -0,0 +1,11 @@
extends PlayerState
func _on_airborne_state_physics_processing(_delta: float) -> void:
if player_controller.is_on_floor():
if player_controller.check_fall_speed():
player_controller.camera_effects.add_fall_kick(2.0)
player_controller.state_chart.send_event("onGrounded")
player_controller.current_fall_velocity += player_controller.velocity.y

View File

@@ -0,0 +1 @@
uid://u17omde74n8t

View File

@@ -0,0 +1,10 @@
class_name PlayerState extends Node
@export var debug : bool = false
var player_controller : PlayerController
func _ready() -> void:
if %StateMachine and %StateMachine is PlayerStateMachine:
player_controller = %StateMachine.player_controller

View File

@@ -0,0 +1 @@
uid://yeyejbcbr1da

View File

@@ -0,0 +1,12 @@
extends PlayerState
func _on_crouching_state_physics_processing(delta: float) -> void:
player_controller.camera.update_camera_height(delta, -1)
if not Input.is_action_pressed("crouch") and player_controller.is_on_floor() and not player_controller.crouch_check.is_colliding():
player_controller.state_chart.send_event("onStanding")
func _on_crouching_state_entered() -> void:
player_controller.crouch()

View File

@@ -0,0 +1 @@
uid://bnmnicp5nu3u2

View File

@@ -0,0 +1,10 @@
extends PlayerState
func _on_grounded_state_physics_processing(delta: float) -> void:
if Input.is_action_just_pressed("jump") and player_controller.is_on_floor():
player_controller.jump()
player_controller.state_chart.send_event("onAirborne")
if not player_controller.is_on_floor():
player_controller.state_chart.send_event("onAirborne")

View File

@@ -0,0 +1 @@
uid://dk8ukj487ryhj

View File

@@ -0,0 +1,6 @@
extends PlayerState
func _on_idle_state_processing(delta: float) -> void:
if player_controller and player_controller._input_dir.length() > 0:
player_controller.state_chart.send_event("onMoving")

View File

@@ -0,0 +1 @@
uid://d03p2engkhyjc

View File

@@ -0,0 +1,6 @@
extends PlayerState
func _on_moving_state_physics_processing(delta: float) -> void:
if player_controller._input_dir.length() == 0 and player_controller.velocity.length() < 0.5:
player_controller.state_chart.send_event("onIdle")

View File

@@ -0,0 +1 @@
uid://dc5dk6fyv83gt

View File

@@ -0,0 +1,10 @@
extends PlayerState
func _on_sprinting_state_processing(delta: float) -> void:
if not Input.is_action_pressed("sprint"):
player_controller.state_chart.send_event("onWalking")
func _on_sprinting_state_entered() -> void:
player_controller.sprint()

View File

@@ -0,0 +1 @@
uid://b40akfam2xgt

View File

@@ -0,0 +1,12 @@
extends PlayerState
func _on_standing_state_physics_processing(delta: float) -> void:
player_controller.camera.update_camera_height(delta, 1)
if Input.is_action_pressed("crouch") and player_controller.is_on_floor():
player_controller.state_chart.send_event("onCrouching")
func _on_standing_state_entered() -> void:
player_controller.stand()

View File

@@ -0,0 +1 @@
uid://c1eixxgwl2exy

View File

@@ -0,0 +1,10 @@
extends PlayerState
func _on_walking_state_processing(delta: float) -> void:
if Input.is_action_pressed("sprint"):
player_controller.state_chart.send_event("onSprinting")
func _on_walking_state_entered() -> void:
player_controller.walk()

View File

@@ -0,0 +1 @@
uid://bstd2nopn6ur