P7.8: Port FPS controller to netfox BaseNetInput pattern

- 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
This commit is contained in:
2026-07-02 17:45:03 -04:00
parent e7299b17e9
commit 926446e5cf
7 changed files with 341 additions and 309 deletions
@@ -1,21 +1,21 @@
## FPSCharacterController — first-person character for a networked tactical shooter.
##
## Bridges Godot Input → SimulationServer (GDExtension C++ core).
## 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)
##
## Connects to SimulationServer via `apply_input()` each frame.
## Reads server snapshot data and updates the node transform.
##
## 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.
## Mouse Look: Capture mouse on click, yaw rotates body, pitch rotates camera.
class_name FPSCharacterController
extends CharacterBody3D
@@ -23,7 +23,7 @@ extends CharacterBody3D
# Exports — tune per weapon / game feel
# ---------------------------------------------------------------------------
## Mouse look sensitivity. At default 0.005, 100 px mouse motion = 0.5° rotation.
## 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).
@@ -35,9 +35,6 @@ extends CharacterBody3D
## Maximum pitch angle (degrees) — prevents looking upside down.
@export_range(1, 89) var max_pitch: float = 89.0
## Entity ID assigned by SimulationServer. Set via spawn_entity() response.
@export var entity_id: int = -1
## If true, pressing Sprint toggles between walk/sprint. If false, hold to sprint.
@export var walk_toggle: bool = true
@@ -59,13 +56,25 @@ extends CharacterBody3D
## 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
# ---------------------------------------------------------------------------
## Reference to SimulationServer singleton (set in _ready).
var _server: Object = null
## Current look rotation.
var _yaw: float = 0.0
var _pitch: float = 0.0
@@ -74,7 +83,7 @@ var _pitch: float = 0.0
var _crouch_target: float = 0.0
var _crouch_current: float = 0.0
## Walk-toggle state. If walk_toggle is true, toggles each time sprint input activates.
## Walk-toggle state.
var _sprint_active: bool = false
var _sprint_pressed_last: bool = false
@@ -82,24 +91,14 @@ var _sprint_pressed_last: bool = false
var _crouch_active: bool = false
var _crouch_pressed_last: bool = false
## Input sequence counter — increment each frame for server reconciliation.
var _input_sequence: int = 0
## Cached input dictionary (avoids alloc per frame).
var _input_dict: Dictionary = {}
## Prediction controller reference (set by ClientPrediction system).
## When non-null and prediction_enabled, the controller uses client-side
## prediction with local movement for instant feedback instead of
## reading position from the SimulationServer entity directly.
var _prediction: Node = null
## Mouse capture state.
var _mouse_captured: bool = false
var _mouse_clicked_this_frame: bool = false
## Weapon manager reference.
var _weapon: WeaponManager = null
## 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)
@@ -119,17 +118,15 @@ var _capsule_radius: float = 0.0
# ---------------------------------------------------------------------------
func _ready() -> void:
# Find SimulationServer
if Engine.has_singleton("SimulationServer"):
_server = Engine.get_singleton("SimulationServer")
else:
push_warning("FPSCharacterController: SimulationServer singleton not found. " +
"Running in standalone (local physics) mode.")
# 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:
# Try to find any Camera3D child
for child in get_children():
if child is Camera3D:
_camera = child
@@ -149,9 +146,6 @@ func _ready() -> void:
_capsule_shape.height = _stand_capsule_height
break
# Start with mouse captured
_capture_mouse(true)
# Initialize yaw/pitch from current transform
_yaw = rotation.y
if _camera:
@@ -161,20 +155,20 @@ func _ready() -> void:
_crouch_current = 0.0
_update_crouch(0.0)
# Find weapon manager child
for child in get_children():
if child is WeaponManager:
_weapon = child
break
# 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) — negative = look down, positive = look up
# 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))
@@ -183,12 +177,11 @@ func _input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
if not _mouse_captured:
_capture_mouse(true)
else:
# LMB fires weapon — handled in _physics_process via shoot_pressed
_mouse_clicked_this_frame = 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)
@@ -199,21 +192,60 @@ func _unhandled_input(event: InputEvent) -> void:
# ---------------------------------------------------------------------------
func _physics_process(delta: float) -> void:
# PREDICTION HOOK: capture pre-input state snapshot.
# Must be called BEFORE any input processing or movement so the
# snapshot represents the state at the start of this tick.
if _prediction and _prediction.prediction_enabled:
_prediction.on_before_tick()
# 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")
var shoot_pressed := _mouse_clicked_this_frame or Input.is_action_pressed(&"shoot")
var aim_pressed := Input.is_action_pressed(&"aim")
_mouse_clicked_this_frame = false
# 2. Walk toggle logic
if walk_toggle:
@@ -234,13 +266,11 @@ func _physics_process(delta: float) -> void:
# 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
@@ -248,95 +278,90 @@ func _physics_process(delta: float) -> void:
if _camera:
_camera.rotation.x = _pitch
# 5b. Weapon fire with rate limiting via WeaponManager
var should_fire: bool = false
var time_since_engine_start: float = Engine.get_process_ticks() * get_physics_process_delta_time()
if _weapon:
should_fire = _weapon.try_fire(time_since_engine_start)
elif shoot_pressed:
# No weapon manager — fire every tick (unlimited)
should_fire = true
# 6. Do local CharacterBody3D physics
_move_local(input_dir, delta, jump_pressed)
if _server != null:
if should_fire:
_server.fire_weapon(entity_id)
# 6. Build input dictionary (all modes — mutate cached dict to avoid alloc)
_input_dict["move_direction"] = input_dir
_input_dict["look_yaw"] = rad_to_deg(_yaw)
_input_dict["look_pitch"] = rad_to_deg(_pitch)
_input_dict["jump"] = jump_pressed
_input_dict["crouch"] = _crouch_active
_input_dict["sprint"] = _sprint_active
_input_dict["shoot"] = should_fire
_input_dict["aim"] = aim_pressed
_input_dict["input_sequence"] = _input_sequence
# ---------------------------------------------------------------------------
# Rollback tick — called by RollbackSynchronizer for deterministic movement
# ---------------------------------------------------------------------------
# 6b. Route input to appropriate handler.
if _prediction and _prediction.prediction_enabled:
# Client prediction: input sending is handled by the prediction
# controller's on_after_tick() call at the end of this function.
pass
elif _server != null and entity_id >= 0:
# Listen server: send to local SimulationServer.
_server.apply_input(entity_id, _input_dict)
## 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
# Check for hit feedback from last tick
if Engine.has_singleton("SimulationServer") or _server:
var hit_result: Dictionary = _server.get_last_hit_result()
if hit_result.get("hit", false) and _weapon:
var hit_pos := Vector3.ZERO # approximate — we don't have exact world hit position from server yet
_weapon.on_hit_confirmed(hit_pos, hit_result.get("damage", 0.0), hit_result.get("killed", false))
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")
_input_sequence += 1
# Apply yaw rotation
_yaw = yaw
rotation.y = _yaw
# 7. Update position.
# Architecture:
# - Client prediction: do local CharacterBody3D movement (instant feedback),
# the prediction controller handles reconciliation when authoritative
# server state arrives.
# - Listen server: read authoritative position from SimulationServer entity.
# - Standalone (no server): fallback CharacterBody3D movement.
if _prediction and _prediction.prediction_enabled:
# Client prediction path — local movement for instant feedback.
_move_local(input_dir, delta, jump_pressed)
elif _server != null and entity_id >= 0:
# Listen server: read from SimulationServer entity.
var entity = _server.get_entity(entity_id)
if entity != null and entity.is_alive():
# Server position is ground truth — apply it to the visual body
# (only correct on listen-server; pure clients must use replication)
global_position = entity.position
# 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:
# Standalone mode: do local CharacterBody3D physics
_move_local(input_dir, delta, jump_pressed)
# 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)
# PREDICTION HOOK: post-tick — send input to server via RPC.
# Must be called AFTER local movement so the prediction controller
# can send the input that produced this tick's movement.
if _prediction and _prediction.prediction_enabled:
_prediction.on_after_tick(_input_dict)
# 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)
# ---------------------------------------------------------------------------
## Walk speed (standalone mode).
@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
func _move_local(input_dir: Vector3, delta: float, jump: bool) -> void:
# Simple CharacterBody3D movement for standalone testing
var target_speed: float = local_walk_speed
if _sprint_active:
target_speed = local_sprint_speed
@@ -369,7 +394,7 @@ func _move_local(input_dir: Vector3, delta: float, jump: bool) -> void:
func _update_crouch(amount: float) -> void:
## amount: 0.0 = standing, 1.0 = fully crouched
# Eye height — camera position relative to body origin (character feet at origin)
# Eye height — camera position relative to body origin
if _camera:
_camera.position.y = lerpf(eye_height_stand, eye_height_crouch, amount)
@@ -377,8 +402,7 @@ func _update_crouch(amount: float) -> void:
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:
# shift = (crouch_total - stand_total) / 2, negative = moves down
# 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)
@@ -397,7 +421,6 @@ func _get_move_direction() -> Vector3:
dir.x -= 1.0
if Input.is_action_pressed(&"move_right"):
dir.x += 1.0
# Normalize for analog stick deadzone
if dir.length_squared() > 0.0:
dir = dir.normalized()
return dir
@@ -414,42 +437,47 @@ func _capture_mouse(capture: bool) -> void:
# ---------------------------------------------------------------------------
# Public API
# Public API — used by PlayerNetInput and external managers
# ---------------------------------------------------------------------------
## Set the entity ID assigned by SimulationServer after spawning.
func set_entity_id(id: int) -> void:
entity_id = id
## 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
## Set the prediction controller for client-side prediction/reconciliation.
## The prediction node must have on_before_tick() / on_after_tick() methods.
## Typically a ClientPrediction instance added as a child or sibling.
func set_prediction(prediction_node: Node) -> void:
_prediction = prediction_node
## 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
@@ -459,15 +487,3 @@ func reset_pose() -> void:
_crouch_current = 0.0
_crouch_target = 0.0
_update_crouch(0.0)
## Get the look direction as a normalized Vector3 in world space.
## Uses the current yaw/pitch to compute a forward vector.
func get_look_direction() -> Vector3:
var yaw_rad: float = _yaw
var pitch_rad: float = _pitch
return Vector3(
cos(pitch_rad) * sin(yaw_rad),
-sin(pitch_rad),
cos(pitch_rad) * cos(yaw_rad)
).normalized()