## FPSCharacterController — first-person character for a networked tactical shooter. ## ## Handles mouse look, WASD movement, sprint toggle, hold-to-crouch, jump. ## The rollback-aware movement runs inside _rollback_tick() when netfox is active. ## Falls back to _move_local() for standalone / non-networked mode. ## ## Usage (scene tree): ## FPSCharacterController (CharacterBody3D) ## ├── FpsCamera (Camera3D) — script: fps_camera.gd ## ├── CollisionShape3D (capsule) ## ├── PlayerNetInput — netfox input gathering ## ├── TickInterpolator — smooth visuals for remotes ## └── (optional weapons/arms child) ## ## Mouse Look: Capture mouse on click, yaw rotates body, pitch rotates camera. ## Walk Toggle: Tap Shift to toggle sprint on/off. ## Crouch: Hold Ctrl to crouch. Smooth lerp transition. ## Jump: Press Space. class_name FPSCharacterController extends CharacterBody3D # --------------------------------------------------------------------------- # Exports — tune per weapon / game feel # --------------------------------------------------------------------------- ## Mouse look sensitivity. At default 0.005, 100 px mouse motion = 0.5 degree rotation. @export_range(0.001, 0.1, 0.0005) var mouse_sensitivity: float = 0.005 ## Mouse vertical sensitivity multiplier (1.0 = same as horizontal). @export_range(0.1, 2.0, 0.05) var mouse_vertical_ratio: float = 0.8 ## Invert vertical look. @export var invert_y: bool = false ## Maximum pitch angle (degrees) — prevents looking upside down. @export_range(1, 89) var max_pitch: float = 89.0 ## If true, pressing Sprint toggles between walk/sprint. If false, hold to sprint. @export var walk_toggle: bool = true ## If true, crouch toggles on press. If false, hold Ctrl to crouch. @export var crouch_toggle: bool = false ## Time in seconds for crouch height transition (lerp). @export var crouch_transition_time: float = 0.15 ## Eye height when standing (Godot units, from feet). @export var eye_height_stand: float = 1.7 ## Eye height when crouching. @export var eye_height_crouch: float = 0.9 ## Collision shape height when standing. @export var collision_height_stand: float = 1.8 ## Collision shape height when crouching. @export var collision_height_crouch: float = 1.0 # --- Standalone movement parameters (used when netfox is not active) --- ## Walk speed (standalone mode / single-player). @export var local_walk_speed: float = 5.0 ## Sprint speed (standalone mode). @export var local_sprint_speed: float = 8.0 ## Crouch speed (standalone mode). @export var local_crouch_speed: float = 2.5 ## Jump velocity (standalone mode). @export var local_jump_velocity: float = 4.5 ## Gravity (standalone mode). @export var local_gravity: float = 15.0 ## Acceleration (standalone mode). @export var local_acceleration: float = 12.0 # --------------------------------------------------------------------------- # Internal state # --------------------------------------------------------------------------- ## Current look rotation. var _yaw: float = 0.0 var _pitch: float = 0.0 ## Crouch lerp target (0.0 = standing, 1.0 = full crouch). var _crouch_target: float = 0.0 var _crouch_current: float = 0.0 ## Walk-toggle state. var _sprint_active: bool = false var _sprint_pressed_last: bool = false ## Crouch toggle state. var _crouch_active: bool = false var _crouch_pressed_last: bool = false ## Mouse capture state. var _mouse_captured: bool = false ## Whether netfox rollback is active (set by parent in network mode). var _netfox_active: bool = false ## Whether this is the local player (only the local player captures mouse). var _is_local_player: bool = false # --------------------------------------------------------------------------- # Node references (set in _ready) # --------------------------------------------------------------------------- var _camera: Node3D = null var _collision_shape: CollisionShape3D = null var _capsule_shape: CapsuleShape3D = null # Cached standing/crouching shape parameters. var _stand_capsule_height: float = 0.0 var _crouch_capsule_height: float = 0.0 var _capsule_radius: float = 0.0 # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: # Determine if this is the local player _is_local_player = multiplayer.get_unique_id() == multiplayer.get_multiplayer_authority() # Check if netfox rollback is available _netfox_active = Engine.has_singleton(&"NetworkRollback") # Locate camera _camera = get_node_or_null("%FpsCamera") if _camera == null: for child in get_children(): if child is Camera3D: _camera = child break if _camera == null: push_error("FPSCharacterController: No Camera3D child found. Mouse look disabled.") # Locate collision shape for child in get_children(): if child is CollisionShape3D: _collision_shape = child if child.shape is CapsuleShape3D: _capsule_shape = child.shape _capsule_radius = _capsule_shape.radius _stand_capsule_height = collision_height_stand - 2.0 * _capsule_radius _crouch_capsule_height = collision_height_crouch - 2.0 * _capsule_radius _capsule_shape.height = _stand_capsule_height break # Initialize yaw/pitch from current transform _yaw = rotation.y if _camera: _pitch = _camera.rotation.x # Initialize crouch animation state _crouch_current = 0.0 _update_crouch(0.0) # Start with mouse captured on local player _set_local_player(_is_local_player) func _input(event: InputEvent) -> void: if not _is_local_player: return # Mouse look if event is InputEventMouseMotion and _mouse_captured: var rel: Vector2 = event.relative # Yaw (body rotation) _yaw -= rel.x * mouse_sensitivity # Pitch (camera rotation) var invert: float = -1.0 if invert_y else 1.0 _pitch += rel.y * mouse_sensitivity * mouse_vertical_ratio * invert _pitch = clamp(_pitch, deg_to_rad(-max_pitch), deg_to_rad(max_pitch)) # Mouse capture toggle if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: if not _mouse_captured: _capture_mouse(true) func _unhandled_input(event: InputEvent) -> void: if not _is_local_player: return # Escape to release mouse if event.is_action_pressed("ui_cancel"): _capture_mouse(false) # --------------------------------------------------------------------------- # Main loop # --------------------------------------------------------------------------- func _physics_process(delta: float) -> void: # When netfox rollback is active, movement is driven by _rollback_tick(). # We only handle local-only visual updates here: crouch animation, rotation. if _netfox_active: _update_visual_state(delta) return # Standalone / no-netfox mode: do full local movement _standalone_physics(delta) ## Updates visual state without applying movement — called every physics frame ## when netfox rollback drives the actual movement. func _update_visual_state(delta: float) -> void: # Walk toggle logic (frame-rate dependent) if walk_toggle: var sprint_pressed := Input.is_action_pressed(&"sprint") if sprint_pressed and not _sprint_pressed_last: _sprint_active = not _sprint_active _sprint_pressed_last = sprint_pressed else: _sprint_active = Input.is_action_pressed(&"sprint") # Crouch toggle logic if crouch_toggle: var crouch_pressed := Input.is_action_pressed(&"crouch") if crouch_pressed and not _crouch_pressed_last: _crouch_active = not _crouch_active _crouch_pressed_last = crouch_pressed else: _crouch_active = Input.is_action_pressed(&"crouch") # Crouch height transition (visual only) var target_crouch: float = 1.0 if _crouch_active else 0.0 _crouch_target = target_crouch var crouch_speed: float = 1.0 / max(crouch_transition_time, 0.001) if _crouch_current < _crouch_target: _crouch_current = min(_crouch_current + crouch_speed * delta, _crouch_target) elif _crouch_current > _crouch_target: _crouch_current = max(_crouch_current - crouch_speed * delta, _crouch_target) _update_crouch(_crouch_current) # Apply rotation (visual only — authoritative rotation from rollback) rotation.y = _yaw if _camera: _camera.rotation.x = _pitch ## Full physics update for standalone / non-networked mode. func _standalone_physics(delta: float) -> void: # 1. Capture input state var input_dir := _get_move_direction() var jump_pressed := Input.is_action_just_pressed(&"jump") var sprint_pressed := Input.is_action_pressed(&"sprint") var crouch_pressed := Input.is_action_pressed(&"crouch") # 2. Walk toggle logic if walk_toggle: if sprint_pressed and not _sprint_pressed_last: _sprint_active = not _sprint_active _sprint_pressed_last = sprint_pressed else: _sprint_active = sprint_pressed # 3. Crouch toggle logic if crouch_toggle: if crouch_pressed and not _crouch_pressed_last: _crouch_active = not _crouch_active _crouch_pressed_last = crouch_pressed else: _crouch_active = crouch_pressed # 4. Crouch height transition var target_crouch: float = 1.0 if _crouch_active else 0.0 _crouch_target = target_crouch var crouch_speed: float = 1.0 / max(crouch_transition_time, 0.001) if _crouch_current < _crouch_target: _crouch_current = min(_crouch_current + crouch_speed * delta, _crouch_target) elif _crouch_current > _crouch_target: _crouch_current = max(_crouch_current - crouch_speed * delta, _crouch_target) _update_crouch(_crouch_current) # 5. Apply rotation rotation.y = _yaw if _camera: _camera.rotation.x = _pitch # 6. Do local CharacterBody3D physics _move_local(input_dir, delta, jump_pressed) # --------------------------------------------------------------------------- # Rollback tick — called by RollbackSynchronizer for deterministic movement # --------------------------------------------------------------------------- ## Rollback-aware movement tick. Called by RollbackSynchronizer each network tick ## when netfox is active. Uses CharacterBody3D physics (move_and_slide) for proper ## acceleration, slopes, and collision response. func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void: # Find PlayerNetInput — it has the input state for this tick var input_node = _find_input_node() if input_node == null: return var dir: Vector3 = input_node.get(&"move_direction") var yaw: float = input_node.get(&"look_yaw") var jump: bool = input_node.get(&"jump") var sprint: bool = input_node.get(&"sprint") var crouch: bool = input_node.get(&"crouch") # Apply yaw rotation _yaw = yaw rotation.y = _yaw # Determine speed var speed: float = local_walk_speed if sprint: speed = local_sprint_speed if crouch: speed = local_crouch_speed # Apply movement with acceleration for smooth feel if dir != Vector3.ZERO: var wish_dir := (transform.basis * dir).normalized() var target_vel := wish_dir * speed # Horizontal acceleration (smooth speed changes) var h_vel := Vector3(velocity.x, 0.0, velocity.z) h_vel = h_vel.move_toward(target_vel, local_acceleration * delta) velocity.x = h_vel.x velocity.z = h_vel.z else: # Decelerate when no input velocity.x = move_toward(velocity.x, 0.0, local_acceleration * delta) velocity.z = move_toward(velocity.z, 0.0, local_acceleration * delta) # Gravity if not is_on_floor(): velocity.y -= local_gravity * delta elif not jump: velocity.y = 0.0 # Jump if jump and is_on_floor(): velocity.y = local_jump_velocity move_and_slide() # Track crouch/sprint state for visual updates _crouch_active = crouch _sprint_active = sprint func _find_input_node(): var parent := get_parent() if parent == null: return null for child in parent.get_children(): if child.name == "PlayerNetInput": return child # Try own children for child in get_children(): if child.name == "PlayerNetInput": return child return null # --------------------------------------------------------------------------- # Local movement (standalone / no-server fallback) # --------------------------------------------------------------------------- func _move_local(input_dir: Vector3, delta: float, jump: bool) -> void: var target_speed: float = local_walk_speed if _sprint_active: target_speed = local_sprint_speed if _crouch_active: target_speed = local_crouch_speed var wish_dir := (transform.basis * input_dir).normalized() var target_vel := wish_dir * target_speed # Horizontal acceleration var h_vel := Vector3(velocity.x, 0.0, velocity.z) h_vel = h_vel.move_toward(target_vel, local_acceleration * delta) velocity.x = h_vel.x velocity.z = h_vel.z # Gravity if not is_on_floor(): velocity.y -= local_gravity * delta # Jump if jump and is_on_floor(): velocity.y = local_jump_velocity move_and_slide() # --------------------------------------------------------------------------- # Crouch height management # --------------------------------------------------------------------------- func _update_crouch(amount: float) -> void: ## amount: 0.0 = standing, 1.0 = fully crouched # Eye height — camera position relative to body origin if _camera: _camera.position.y = lerpf(eye_height_stand, eye_height_crouch, amount) # Collision shape — capsule height (cylindrical section) if _capsule_shape: _capsule_shape.height = lerpf(_stand_capsule_height, _crouch_capsule_height, amount) # Move the collision shape center so the capsule base stays on the ground if _collision_shape: _collision_shape.position.y = lerpf(0.0, (collision_height_crouch - collision_height_stand) * 0.5, amount) # --------------------------------------------------------------------------- # Input helpers # --------------------------------------------------------------------------- func _get_move_direction() -> Vector3: var dir := Vector3.ZERO if Input.is_action_pressed(&"move_forward") or Input.is_action_pressed(&"move_up"): dir.z -= 1.0 if Input.is_action_pressed(&"move_backward") or Input.is_action_pressed(&"move_down"): dir.z += 1.0 if Input.is_action_pressed(&"move_left"): dir.x -= 1.0 if Input.is_action_pressed(&"move_right"): dir.x += 1.0 if dir.length_squared() > 0.0: dir = dir.normalized() return dir func _capture_mouse(capture: bool) -> void: if capture == _mouse_captured: return _mouse_captured = capture if capture: Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) else: Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) # --------------------------------------------------------------------------- # Public API — used by PlayerNetInput and external managers # --------------------------------------------------------------------------- ## Mark this as the local player (enables mouse capture and input). func _set_local_player(enabled: bool) -> void: _is_local_player = enabled if enabled: _capture_mouse(true) # TickInterpolator should be disabled for the local player var ti := get_node_or_null("TickInterpolator") if ti != null and ti.has_method(&"set_enabled"): ti.set_enabled(false) elif ti != null: ti.enabled = false ## Get current yaw (radians) — for PlayerNetInput sync. func get_current_yaw() -> float: return _yaw ## Get current pitch (radians) — for PlayerNetInput sync. func get_current_pitch() -> float: return _pitch ## Get current crouch amount (0.0 = standing, 1.0 = fully crouched). func get_crouch_amount() -> float: return _crouch_current ## Is the player currently sprinting? func is_sprinting() -> bool: return _sprint_active ## Is the player currently crouching? func is_crouching() -> bool: return _crouch_active ## Force look direction (useful for spectator / spawn reset). func set_look(yaw_rad: float, pitch_rad: float) -> void: _yaw = yaw_rad _pitch = clamp(pitch_rad, deg_to_rad(-max_pitch), deg_to_rad(max_pitch)) ## Reset to default standing state. func reset_pose() -> void: _sprint_active = false _crouch_active = false _sprint_pressed_last = false _crouch_pressed_last = false _crouch_current = 0.0 _crouch_target = 0.0 _update_crouch(0.0)