Files
tactical-shooter/scripts/network/network_manager.gd
T
shawn 926446e5cf 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
2026-07-02 17:45:03 -04:00

272 lines
10 KiB
GDScript

## NetworkManager — netfox-aware transport with graceful ENet fallback
##
## Dual-path architecture:
## Path A (netfox available) → NetworkEvents drives lifecycle signals
## Path B (headless/export) → ENetMultiplayerPeer drives lifecycle signals
##
## The server always uses ENetMultiplayerPeer as the underlying transport
## (netfox layers on top of Godot's MultiplayerAPI). When netfox NetworkEvents
## is available, it provides lifecycle signals (on_peer_join, etc.) and the
## ENet signal connections are skipped to avoid double emissions. When netfox
## is unavailable (headless mode), the built-in ENet peer_connected/
## peer_disconnected signals are used directly.
##
## Broadcast RPCs (player spawn/despawn) work identically in both paths
## since they use Godot's built-in MultiplayerAPI, which netfox enhances
## without replacing. Round state/scores are now distributed via
## round_manager's netfox Self-RPC pattern directly.
##
## Architecture:
## server mode → start_server(port) → ENetMultiplayerPeer server
## client mode → join_server(host,port)→ ENetMultiplayerPeer client
## netfox path → NetworkEvents signals when available (editor)
## fallback → ENet peer signals in headless mode
##
## Channels (3-lane layout):
## 0 unreliable-ordered → 128Hz input / transform deltas
## 1 reliable-ordered → game events, spawn, damage, chat
## 2 unreliable → telemetry / VOIP metadata
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal server_started(port: int)
signal server_stopped()
signal player_connected(peer_id: int)
signal player_disconnected(peer_id: int)
signal connection_succeeded()
signal connection_failed(error_message: String)
# --- Player replication signals (emitted on all peers after RPC broadcast) ---
signal remote_player_spawned(peer_id: int, pos: Vector3)
signal remote_player_despawned(peer_id: int)
# --- Client prediction signals ---
## Emitted on the server when a client sends input. payload: {peer_id, tick, input_dict}
signal client_input_received(peer_id: int, tick: int, input_dict: Dictionary)
## Emitted on the client when the server sends authoritative state.
## entity_id: the simulation entity this state belongs to.
signal server_state_received(entity_id: int, snapshot_dict: Dictionary)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const DEFAULT_PORT: int = 34197
const CHANNELS: int = 3
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var is_server: bool = false : get = _is_server
var is_client: bool = false : get = _is_client
var peer: ENetMultiplayerPeer = null
var max_clients: int = 16
# netfox overlay (optional — only available when netfox plugin is active)
var _netfox_events = null
var _netfox_events_connected: bool = false
func _is_server() -> bool:
return is_server
func _is_client() -> bool:
return is_client
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Try to connect to netfox NetworkEvents singleton (non-critical)
# We use StringName for singleton access to avoid parser errors
# when netfox types aren't available (headless mode).
_try_connect_netfox()
func _try_connect_netfox() -> void:
# Use singleton name string to avoid referencing netfox types directly
if not Engine.has_singleton("NetworkEvents"):
_netfox_events_connected = false
print("[NetworkManager] netfox not available — using ENet directly")
return
var events = Engine.get_singleton("NetworkEvents")
if events == null or not events.has_signal("on_server_start"):
_netfox_events_connected = false
print("[NetworkManager] netfox singleton found but unexpected shape — using ENet directly")
return
_netfox_events = events
_netfox_events.on_server_start.connect(_on_netfox_server_start)
_netfox_events.on_server_stop.connect(_on_netfox_server_stop)
_netfox_events.on_client_start.connect(_on_netfox_client_start)
_netfox_events.on_client_stop.connect(_on_netfox_client_stop)
_netfox_events.on_peer_join.connect(_on_netfox_peer_join)
_netfox_events.on_peer_leave.connect(_on_netfox_peer_leave)
_netfox_events_connected = true
print("[NetworkManager] netfox NetworkEvents overlay active")
# netfox signal handlers
func _on_netfox_server_start() -> void:
print("[NetworkManager] Server started (netfox)")
is_server = true
func _on_netfox_server_stop() -> void:
print("[NetworkManager] Server stopped (netfox)")
is_server = false
func _on_netfox_client_start(id: int) -> void:
print("[NetworkManager] Client started (netfox, id=%d)" % id)
is_client = true
func _on_netfox_client_stop() -> void:
print("[NetworkManager] Client stopped (netfox)")
is_client = false
func _on_netfox_peer_join(id: int) -> void:
print("[NetworkManager] Peer joined (netfox, id=%d)" % id)
player_connected.emit(id)
func _on_netfox_peer_leave(id: int) -> void:
print("[NetworkManager] Peer left (netfox, id=%d)" % id)
player_disconnected.emit(id)
# ---------------------------------------------------------------------------
# Server API
# ---------------------------------------------------------------------------
func start_server(port: int = DEFAULT_PORT) -> Error:
if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED:
stop()
# If ServerConfig is available, use it for max_clients
if ServerConfig and ServerConfig.has_method(&"get_config_path"):
max_clients = ServerConfig.max_players
peer = ENetMultiplayerPeer.new()
peer.set_bind_ip("*")
var err: Error = peer.create_server(port, max_clients, CHANNELS, 0, 0)
if err != OK:
peer = null
return err
multiplayer.multiplayer_peer = peer
# Only connect ENet signals when netfox is unavailable to avoid
# double-emission with NetworkEvents.on_peer_join/on_peer_leave
if not _netfox_events_connected:
multiplayer.multiplayer_peer.peer_connected.connect(_on_peer_connected)
multiplayer.multiplayer_peer.peer_disconnected.connect(_on_peer_disconnected)
print("[NetworkManager] Using ENet peer signals (netfox not available)")
else:
print("[NetworkManager] Using netfox NetworkEvents for lifecycle signals")
is_server = true
server_started.emit(port)
print("[NetworkManager] Server started on port %d" % port)
return OK
func stop() -> void:
if not peer:
return
if is_server:
# Only disconnect ENet signals if they were connected
# (when netfox was unavailable, see start_server)
if not _netfox_events_connected:
if multiplayer.multiplayer_peer.peer_connected.is_connected(_on_peer_connected):
multiplayer.multiplayer_peer.peer_connected.disconnect(_on_peer_connected)
if multiplayer.multiplayer_peer.peer_disconnected.is_connected(_on_peer_disconnected):
multiplayer.multiplayer_peer.peer_disconnected.disconnect(_on_peer_disconnected)
peer.close()
multiplayer.multiplayer_peer = null
peer = null
is_server = false
is_client = false
server_stopped.emit()
print("[NetworkManager] Stopped")
# ---------------------------------------------------------------------------
# Client API
# ---------------------------------------------------------------------------
func join_server(host: String, port: int = DEFAULT_PORT) -> Error:
if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED:
stop()
peer = ENetMultiplayerPeer.new()
var err: Error = peer.create_client(host, port, CHANNELS, 0, 0)
if err != OK:
peer = null
return err
multiplayer.multiplayer_peer = peer
connection_succeeded.emit()
print("[NetworkManager] Connecting to %s:%d ..." % [host, port])
is_client = true
return OK
# ---------------------------------------------------------------------------
# Player Replication RPCs (broadcast server → all clients)
# ---------------------------------------------------------------------------
@rpc("authority", "call_local", "reliable")
func broadcast_spawn_player(peer_id: int, pos: Vector3, is_team_a: bool) -> void:
if not multiplayer.is_server():
print("[NetworkManager] Client received spawn: peer=%d at (%.1f, %.1f)" % [peer_id, pos.x, pos.z])
remote_player_spawned.emit(peer_id, pos)
@rpc("authority", "call_local", "reliable")
func broadcast_despawn_player(peer_id: int) -> void:
if not multiplayer.is_server():
print("[NetworkManager] Client received despawn: peer=%d" % peer_id)
remote_player_despawned.emit(peer_id)
# ---------------------------------------------------------------------------
# Client Prediction RPCs (Phase 1 — client-side prediction)
# ---------------------------------------------------------------------------
## Client → Server: send raw input for the given local tick.
## Called by ClientPrediction.on_after_tick() each physics tick.
## Uses ENet channel 0 (unreliable-ordered) for lowest-latency input delivery.
@rpc("unreliable", "any_peer", "call_remote", 0)
func send_client_input(tick: int, input_dict: Dictionary) -> void:
if not multiplayer.is_server():
return
var peer_id: int = multiplayer.get_remote_sender_id()
client_input_received.emit(peer_id, tick, input_dict)
## Server → Client: send authoritative entity snapshot for reconciliation.
## Called by server-side code (e.g. GameServer after each tick).
## entity_id identifies which simulation entity this state belongs to.
## Uses ENet channel 1 (reliable-ordered) so state corrections are not dropped.
@rpc("unreliable", "authority", "call_remote", 1)
func send_server_state(entity_id: int, snapshot_dict: Dictionary) -> void:
if multiplayer.is_server():
return
server_state_received.emit(entity_id, snapshot_dict)
# ---------------------------------------------------------------------------
# Event handlers
# ---------------------------------------------------------------------------
func _on_peer_connected(id: int) -> void:
print("[NetworkManager] Peer connected: %d" % id)
player_connected.emit(id)
func _on_peer_disconnected(id: int) -> void:
print("[NetworkManager] Peer disconnected: %d" % id)
player_disconnected.emit(id)
func _process(_delta: float) -> void:
pass
func _exit_tree() -> void:
stop()