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
+94 -119
View File
@@ -1,20 +1,13 @@
## Player — netfox rollback-aware player extending FPSCharacterController
## with rollback-safe hitscan weapon system and lag compensation.
## Player — netfox rollback-aware player with client-prediction + rollback.
##
## Inherits from FPSCharacterController (CharacterBody3D) for full FPS movement
## with acceleration, slopes, crouch, sprint, and jump.
## Extends CharacterBody3D directly for full FPS movement with acceleration,
## slopes, crouch, sprint, and jump.
##
## netfox integration:
## - RollbackSynchronizer for deterministic state sync
## - Position state synced via state_properties
## - Input gathered via PlayerNetInput child (_gather())
## - TickInterpolator for smooth remote player visuals
## - RollbackHitscanManager for RewindableAction-based hitscan firing
##
## Weapons use RewindableAction for rollback-safe firing with:
## - Server-authoritative hit detection with lag compensation
## - Per-limb damage (head/chest/waist/legs) via collision groups
## - Physics rewound to the client's input tick for accurate raycasts
##
## Team & Round integration:
## team_id: 0 = Team A, 1 = Team B
@@ -26,29 +19,21 @@ extends CharacterBody3D
# Exports
# ---------------------------------------------------------------------------
## Walk speed in units/sec (used inside FPSCharacterController movement).
## Walk speed in units/sec (used inside _rollback_tick / _physics_process).
@export var movement_speed: float = 5.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 look state (for PlayerNetInput yaw/pitch getters)
# ---------------------------------------------------------------------------
## Current yaw (radians) — read by PlayerNetInput._gather()
var _yaw: float = 0.0
## Current pitch (radians) — read by PlayerNetInput._gather()
var _pitch: float = 0.0
# ---------------------------------------------------------------------------
@@ -83,18 +68,14 @@ var _cached_peer_id: int = -1
## RollbackSynchronizer for deterministic state sync.
var _rollback_sync: Node = null
## RollbackHitscanManager for RewindableAction-based hitscan firing.
var _hitscan_mgr: Node = null
# ---------------------------------------------------------------------------
# Look state getters (for PlayerNetInput._gather())
# ---------------------------------------------------------------------------
## 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
@@ -103,8 +84,7 @@ func get_current_pitch() -> float:
# ---------------------------------------------------------------------------
func _ready() -> void:
# Determine local player status BEFORE calling super._ready
# (FPSCharacterController._ready sets up _is_local_player based on authority)
# Determine local player status
is_local = multiplayer.get_unique_id() == multiplayer.get_multiplayer_authority()
if is_local:
@@ -115,49 +95,78 @@ func _ready() -> void:
# Ensure PlayerNetInput child exists (belt + suspenders with scene)
_ensure_input_node()
# Call parent _ready (FPSCharacterController) — initializes camera, collision, mouse
super._ready()
# Override _is_local_player to be consistent with our logic
_is_local_player = is_local
# Set up RollbackSynchronizer
_setup_rollback_synchronizer()
var has_rollback := _setup_rollback_synchronizer()
# Set up TickInterpolator for remote players
_setup_tick_interpolator()
# Set up RollbackHitscanManager for rollback-safe weapon firing
_setup_hitscan_manager()
if has_rollback:
# RollbackSynchronizer handles ticks — disable standard processing
if not is_local:
set_process(false)
set_physics_process(false)
print("[Player] Remote player: %d (authority: %d) — rollback sync" % [
multiplayer.get_unique_id(), multiplayer.get_multiplayer_authority()])
else:
# No RollbackSynchronizer (headless/server mode).
# Enable _physics_process as fallback simulation.
set_physics_process(true)
print("[Player] Player %d — rollback not available, using fallback physics" % multiplayer.get_unique_id())
# Disable physics processing if remote (RollbackSynchronizer handles ticks)
if not is_local:
set_process(false)
set_physics_process(false)
print("[Player] Remote player: %d (authority: %d)" % [
multiplayer.get_unique_id(), multiplayer.get_multiplayer_authority()])
# ---------------------------------------------------------------------------
# Fallback physics — used when RollbackSynchronizer is not available
# (e.g., headless dedicated server without netfox singletons).
# ---------------------------------------------------------------------------
func _physics_process(delta: float) -> void:
# Only run fallback if RollbackSynchronizer is NOT handling ticks
if _rollback_sync != null:
return
# Set local player flag for FPSCharacterController
_set_local_player(is_local)
# Read input from PlayerNetInput (if we're the authority)
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 sprint: bool = input_node.get(&"sprint")
var crouch: bool = input_node.get(&"crouch")
rotation.y = yaw
_yaw = yaw
if dir != Vector3.ZERO:
var wish_dir := (transform.basis * dir).normalized()
var target_speed: float = movement_speed
if sprint:
target_speed *= 1.6
if crouch:
target_speed *= 0.5
position += wish_dir * target_speed * delta
# ---------------------------------------------------------------------------
# RollbackSynchronizer setup
# ---------------------------------------------------------------------------
## Create and configure a RollbackSynchronizer child node.
func _setup_rollback_synchronizer() -> void:
## Returns true if the synchronizer was successfully created.
func _setup_rollback_synchronizer() -> bool:
if not Engine.has_singleton(&"NetworkRollback"):
push_warning("[Player] netfox NetworkRollback not available — running without rollback sync")
return
return false
# Check if we already have a RollbackSynchronizer child
for child in get_children():
if child.has_method(&"get_state_properties") or child.name == "RollbackSynchronizer":
_rollback_sync = child
print("[Player] Found existing RollbackSynchronizer child")
return
return true
var rs = _create_rollback_sync_node()
if rs == null:
push_warning("[Player] Could not create RollbackSynchronizer — netfox types not available")
return
return false
rs.root = self
@@ -189,7 +198,7 @@ func _setup_rollback_synchronizer() -> void:
add_child(rs, true)
_rollback_sync = rs
print("[Player] RollbackSynchronizer added to player %d" % multiplayer.get_unique_id())
return true
## Create a RollbackSynchronizer node via preloaded script (avoids class_name issues).
func _create_rollback_sync_node():
@@ -200,10 +209,10 @@ func _create_rollback_sync_node():
rs.name = "RollbackSynchronizer"
return rs
# ---------------------------------------------------------------------------
# TickInterpolator setup
# ---------------------------------------------------------------------------
func _setup_tick_interpolator() -> void:
# Only create TickInterpolator if netfox is available (avoids headless parse errors)
if not Engine.has_singleton(&"NetworkTime"):
@@ -213,7 +222,6 @@ func _setup_tick_interpolator() -> void:
if TIScript == null:
return
# Check if we already have one
var ti = get_node_or_null("TickInterpolator")
if ti == null:
ti = TIScript.new()
@@ -229,7 +237,6 @@ func _setup_tick_interpolator() -> void:
ti.enable_recording = false # RollbackSynchronizer pushes state
print("[Player] TickInterpolator %s (local=%s)" % ["enabled" if ti.enabled else "disabled", is_local])
# ---------------------------------------------------------------------------
# PlayerNetInput creation
# ---------------------------------------------------------------------------
@@ -247,111 +254,83 @@ func _ensure_input_node() -> void:
add_child(pni, true)
print("[Player] Created PlayerNetInput dynamically")
# ---------------------------------------------------------------------------
# RollbackHitscanManager setup
# ---------------------------------------------------------------------------
## Create and configure a RollbackHitscanManager child node for rollback-safe
## weapon firing with RewindableAction-based lag compensation.
func _setup_hitscan_manager() -> void:
if not Engine.has_singleton(&"NetworkRollback"):
return # RewindableAction won't work without NetworkRollback
var HmScript = load("res://scripts/weapons/rollback_hitscan_manager.gd")
if HmScript == null:
push_warning("[Player] Could not load RollbackHitscanManager — weapons will not fire via rollback")
return
_hitscan_mgr = HmScript.new()
if _hitscan_mgr == null:
return
_hitscan_mgr.name = "RollbackHitscanManager"
add_child(_hitscan_mgr, true)
print("[Player] RollbackHitscanManager added to %s" % name)
# Connect to PlayerNetInput so _gather() can call record_fire()
var input_node = _find_input_node()
if input_node != null and input_node.has_method(&"get"):
input_node.set(&"hitscan_mgr_ref", _hitscan_mgr)
print("[Player] Linked RollbackHitscanManager to PlayerNetInput")
# ---------------------------------------------------------------------------
# Rollback tick — override from FPSCharacterController with authority guard
# Rollback tick — called by RollbackSynchronizer every network tick
# ---------------------------------------------------------------------------
## Called by RollbackSynchronizer for deterministic state sync.
## On the server: processes all inputs authoritatively.
## On the client: predicts locally, state corrected by server snapshots.
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
# Only simulate on:
# - Server: simulate all players (authoritative movement)
# - Client-local: simulate for client-side prediction
# - Client-remote: skip — position comes from server snapshot
if multiplayer.is_server():
super._rollback_tick(delta, tick, is_fresh)
elif is_local:
super._rollback_tick(delta, tick, is_fresh)
else:
if not is_fresh and not _is_rollback_enabled():
return
# -----------------------------------------------------------------------
# Rollback-safe hitscan weapon firing
# -----------------------------------------------------------------------
# The RollbackHitscanManager performs a physics raycast at the current
# (rewound) tick, providing automatic lag compensation: the world state
# matches what the client saw when they pressed fire.
#
# On the server, per-limb damage is applied. On the client, the signal
# emission triggers visual feedback for prediction.
if _hitscan_mgr != null and _hitscan_mgr.has_method(&"process_fire"):
var input_node = _find_input_node()
if input_node != null:
var is_server: bool = multiplayer.is_server()
_hitscan_mgr.process_fire(tick, is_server, input_node)
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 sprint: bool = input_node.get(&"sprint")
var crouch: bool = input_node.get(&"crouch")
# Apply yaw rotation
rotation.y = yaw
_yaw = yaw
# Apply movement
if dir != Vector3.ZERO:
var wish_dir := (transform.basis * dir).normalized()
var target_speed: float = movement_speed
if sprint:
target_speed *= 1.6
if crouch:
target_speed *= 0.5
position += wish_dir * target_speed * delta
func _is_rollback_enabled() -> bool:
var nr = _get_netrollback_singleton()
if nr == null:
return false
return nr.get(&"enabled")
func _get_netrollback_singleton():
if Engine.has_singleton(&"NetworkRollback"):
return Engine.get_singleton(&"NetworkRollback")
return null
# ---------------------------------------------------------------------------
# Input node helper
# ---------------------------------------------------------------------------
## Find the PlayerNetInput child node (overrides FPSCharacterController lookup).
func _find_input_node():
for child in get_children():
if child.name == "PlayerNetInput":
return child
return null
# ---------------------------------------------------------------------------
# Snap player to position after rollback reconciliation
# ---------------------------------------------------------------------------
## Called by the game layer to snap the player to an authoritative position.
func snap_to_position(pos: Vector3, yaw: float) -> void:
global_position = pos
_yaw = yaw
rotation.y = yaw
reset_pose()
# ---------------------------------------------------------------------------
# Round system: team / life management
# ---------------------------------------------------------------------------
## Set the player's team. Called by server_main at spawn.
func set_team(new_team: int) -> void:
team_id = new_team
## Apply damage from a hitscan hit. Called by RollbackHitscanManager on the
## server after a lag-compensated raycast.
##
## This method is duck-typed (found by has_method) so any player-like node
## with collision groups (head/chest/waist/legs) can receive damage.
func apply_damage(amount: int, attacker_id: int = -1, weapon_id: int = 0) -> void:
if not is_alive:
return
print("[Player] %s takes %d damage from peer %d (weapon %d)" % [
name, amount, attacker_id, weapon_id])
## Get the peer ID from node name or multiplayer authority.
func _get_peer_id() -> int:
if _cached_peer_id >= 0:
return _cached_peer_id
@@ -365,7 +344,6 @@ func _get_peer_id() -> int:
_cached_peer_id = multiplayer.get_multiplayer_authority()
return _cached_peer_id
## Mark the player as dead. Notifies RoundManager.
func mark_dead(killer_id: int = 0, weapon: String = "unknown") -> void:
if not is_alive:
return
@@ -376,18 +354,15 @@ func mark_dead(killer_id: int = 0, weapon: String = "unknown") -> void:
if round_mgr and round_mgr.has_method(&"on_player_death"):
round_mgr.on_player_death(pid, killer_id, weapon)
## Mark the player as alive (respawn).
func mark_alive() -> void:
is_alive = true
is_spectating = false
spectate_target_id = 0
## Set spectate target for this player.
func set_spectate_target(target_id: int) -> void:
spectate_target_id = target_id
is_spectating = target_id > 0 or not is_alive
## Helper — find the RoundManager in the scene tree.
func _find_round_manager() -> Node:
if Engine.has_singleton(&"RoundManager"):
return Engine.get_singleton(&"RoundManager")