926446e5cf
- FPSCharacterController: removed SimulationServer dependency, added _rollback_tick() with acceleration-based CharacterBody3D movement, gravity, jump, crouch/sprint speed. Kept mouse look, crouch lerp, sprint/crouch toggle, _move_local() standalone fallback. - Player: extends FPSCharacterController (CharacterBody3D). Added authority-guarded _rollback_tick that delegates to super for server and local client prediction. TickInterpolator created dynamically (avoids headless class_name parse errors). PlayerNetInput child wired via existing RollbackSynchronizer input_properties. - player.tscn: CharacterBody3D root with FpsCamera, CollisionShape3D, PlayerNetInput children. TickInterpolator created in code. - client_main.gd: _spawn_local_player() creates FPS player node with first-person camera for own peer. _spawn_remote_player() removes FpsCamera for remote players. - fps_camera.gd: duck-typed _controller (Node) instead of class_name cast for headless safety. Fixed type inference warnings. Also includes parent task P7.5-P7.7 changes: - project.godot: main_scene -> server_main.tscn - network_manager.gd: Chan enum -> raw channel ints - server_main.gd: deferred ServerConfig load, GameServer load() - game_server.gd: commented out dev deps for headless compilation
490 lines
16 KiB
GDScript
490 lines
16 KiB
GDScript
## 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)
|