diff --git a/scripts/network/netfox_bootstrap.gd b/scripts/network/netfox_bootstrap.gd new file mode 100644 index 0000000..ef496b8 --- /dev/null +++ b/scripts/network/netfox_bootstrap.gd @@ -0,0 +1,10 @@ +## NetfoxBootstrap — minimal. All class_names should be registered by +## Godot's global script scanning. We don't reference any netfox +## class_name directly in our code. + +extends Node + +func _ready() -> void: + # netfox singletons are registered by the editor plugin only. + # In headless/export builds, Engine.has_singleton checks handle this. + pass diff --git a/scripts/network/netfox_bootstrap.gd.uid b/scripts/network/netfox_bootstrap.gd.uid new file mode 100644 index 0000000..79d17d9 --- /dev/null +++ b/scripts/network/netfox_bootstrap.gd.uid @@ -0,0 +1 @@ +uid://cwipcjgj8cyym diff --git a/scripts/network/network_manager.gd b/scripts/network/network_manager.gd index 4b3b9f8..b32135c 100644 --- a/scripts/network/network_manager.gd +++ b/scripts/network/network_manager.gd @@ -1,16 +1,28 @@ -## NetworkManager — ENet Transport + Player Replication Singleton +## NetworkManager — netfox-aware transport with graceful ENet fallback ## -## Autoload that wraps Godot 4's ENetMultiplayerPeer and provides -## player spawn/despawn broadcasting to all connected clients. -## Server creates RPC broadcasts; clients receive and emit signals. +## 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 -## replication → _broadcast_spawn_player / _broadcast_despawn_player -## (server→all clients RPC) +## 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 per Phase 0 research): +## Channels (3-lane layout): ## 0 unreliable-ordered → 128Hz input / transform deltas ## 1 reliable-ordered → game events, spawn, damage, chat ## 2 unreliable → telemetry / VOIP metadata @@ -42,18 +54,7 @@ signal server_state_received(entity_id: int, snapshot_dict: Dictionary) # Constants # --------------------------------------------------------------------------- const DEFAULT_PORT: int = 34197 -const CHANNELS: int = 3 # 0=input, 1=events, 2=telemetry - -# Max clients is read from ServerConfig at start_server() time. -# This default is used before ServerConfig is available. -var max_clients: int = 16 - -# Channel indices -enum Chan { - INPUT = 0, - EVENTS = 1, - TELEMETRY = 2, -} +const CHANNELS: int = 3 # --------------------------------------------------------------------------- # State @@ -61,6 +62,11 @@ enum Chan { 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 @@ -68,12 +74,68 @@ func _is_server() -> bool: 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 # --------------------------------------------------------------------------- -## Start a dedicated server on [port]. -## Uses max_clients (which should be set from ServerConfig before calling). -## Returns OK or ERR_* on failure. + func start_server(port: int = DEFAULT_PORT) -> Error: if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED: stop() @@ -84,30 +146,41 @@ func start_server(port: int = DEFAULT_PORT) -> Error: 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 - multiplayer.multiplayer_peer.peer_connected.connect(_on_peer_connected) - multiplayer.multiplayer_peer.peer_disconnected.connect(_on_peer_disconnected) + + # 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 -## Stop the server / disconnect. func stop() -> void: if not peer: return - + if is_server: - multiplayer.multiplayer_peer.peer_connected.disconnect(_on_peer_connected) - multiplayer.multiplayer_peer.peer_disconnected.disconnect(_on_peer_disconnected) - + # 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 @@ -119,7 +192,7 @@ func stop() -> void: # --------------------------------------------------------------------------- # Client API # --------------------------------------------------------------------------- -## Connect to a remote server. + func join_server(host: String, port: int = DEFAULT_PORT) -> Error: if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED: stop() @@ -140,20 +213,18 @@ func join_server(host: String, port: int = DEFAULT_PORT) -> Error: # --------------------------------------------------------------------------- # Player Replication RPCs (broadcast server → all clients) # --------------------------------------------------------------------------- -## Server calls this when a new player joins. -## Broadcasts to all clients so they can create a visual player node. + @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) -## Server calls this when a player leaves. @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) + remote_player_despawned.emit(peer_id) # --------------------------------------------------------------------------- # Client Prediction RPCs (Phase 1 — client-side prediction) @@ -184,6 +255,7 @@ func send_server_state(entity_id: int, snapshot_dict: Dictionary) -> void: # --------------------------------------------------------------------------- # Event handlers # --------------------------------------------------------------------------- + func _on_peer_connected(id: int) -> void: print("[NetworkManager] Peer connected: %d" % id) player_connected.emit(id) @@ -193,8 +265,6 @@ func _on_peer_disconnected(id: int) -> void: player_disconnected.emit(id) func _process(_delta: float) -> void: - # Godot does internal ENet polling via MultiplayerAPI; - # explicit polling is reserved for future custom packet handling. pass func _exit_tree() -> void: diff --git a/scripts/network/player_net_input.gd b/scripts/network/player_net_input.gd new file mode 100644 index 0000000..529e642 --- /dev/null +++ b/scripts/network/player_net_input.gd @@ -0,0 +1,133 @@ +## PlayerNetInput — netfox-compatible input gathering for tactical-shooter. +## +## Extends Node directly (avoids headless class_name issues with BaseNetInput) +## but follows the same pattern: connects to NetworkTime.before_tick_loop to +## call _gather() on each tick. +## +## RollbackSynchronizer references the exported properties as input_properties. +## +## Usage: +## Place as a child of the Player (Node3D). The parent node's +## RollbackSynchronizer will pick up the input_properties paths. +## +## Set hitscan_manager_ref to the RollbackHitscanManager child so that +## _gather() can call record_fire() for RewindableAction-based fire tracking. + +extends Node + +# --------------------------------------------------------------------------- +# Input state — read by RollbackSynchronizer as input_properties +# --------------------------------------------------------------------------- + +## Movement direction in local space (normalized, or zero). +@export var move_direction: Vector3 = Vector3.ZERO + +## Mouse look: yaw (horizontal, radians). +@export var look_yaw: float = 0.0 + +## Mouse look: pitch (vertical, radians). +@export var look_pitch: float = 0.0 + +## Jump pressed this tick. +@export var jump: bool = false + +## Sprint toggle active. +@export var sprint: bool = false + +## Crouch toggle active. +@export var crouch: bool = false + +## Shoot pressed this tick. +@export var shoot: bool = false + +## Aim-down-sights pressed. +@export var aim: bool = false + +# --------------------------------------------------------------------------- +# RollbackHitscanManager reference (set by player.gd) +# --------------------------------------------------------------------------- +## Reference to the RollbackHitscanManager for RewindableAction fire tracking. +var hitscan_mgr_ref: Node = null + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + # Same pattern as netfox's BaseNetInput: + # connect to NetworkTime.before_tick_loop to gather input each tick + # Use Engine.get_singleton instead of direct NetworkTime reference to + # avoid headless parse errors (NetworkTime is an editor-plugin type). + var nt = Engine.get_singleton(&"NetworkTime") if Engine.has_singleton(&"NetworkTime") else null + if nt: + nt.before_tick_loop.connect(_on_before_tick_loop) + else: + push_warning("[PlayerNetInput] netfox NetworkTime not available — " + + "input gathering disabled") + +func _on_before_tick_loop() -> void: + if is_multiplayer_authority(): + _gather() + +# --------------------------------------------------------------------------- +# Input gathering (called before each tick on the authority peer) +# --------------------------------------------------------------------------- + +func _gather() -> void: + # Movement direction + 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() + move_direction = dir + + # Read look direction from parent's FPSCharacterController if available + var parent := get_parent() + if parent and parent.has_method(&"get_current_yaw"): + look_yaw = parent.get_current_yaw() + if parent and parent.has_method(&"get_current_pitch"): + look_pitch = parent.get_current_pitch() + + # Action inputs + jump = Input.is_action_just_pressed(&"jump") + sprint = Input.is_action_pressed(&"sprint") + crouch = Input.is_action_pressed(&"crouch") + shoot = Input.is_action_pressed(&"shoot") + aim = Input.is_action_pressed(&"aim") + + # ----------------------------------------------------------------------- + # Fire recording for RewindableAction rollback sync + # ----------------------------------------------------------------------- + # When the client fires, record the shot in the RollbackHitscanManager's + # RewindableAction. This propagates to the server via netfox sync so + # the server can process the lag-compensated raycast at the correct tick. + # + # We compute the eye position and look direction at gather time for + # the RewindableAction context. The server also reconstructs these from + # the synced state (position + yaw/pitch) for authoritative hit detection. + if shoot and hitscan_mgr_ref != null and hitscan_mgr_ref.has_method(&"record_fire"): + var current_tick: int =_get_current_tick() + var eye_pos: Vector3 = parent.global_position + Vector3(0, 1.7, 0) if parent else Vector3.ZERO + var look_dir: Vector3 = _yaw_pitch_to_dir(look_yaw, look_pitch) + hitscan_mgr_ref.record_fire(current_tick, eye_pos, look_dir) + +## Get the current network tick, or 0 if NetworkTime is not available. +func _get_current_tick() -> int: + var nt = Engine.get_singleton(&"NetworkTime") if Engine.has_singleton(&"NetworkTime") else null + if nt != null and nt.has_method(&"get_tick"): + return nt.get_tick() + if nt != null: + return nt.tick if nt.get("tick") != null else 0 + return 0 + +## Convert yaw/pitch to a forward direction vector. +static func _yaw_pitch_to_dir(yaw: float, pitch: float) -> Vector3: + var cp := cos(pitch) + return Vector3(cp * sin(yaw), sin(pitch), cp * cos(yaw)) diff --git a/scripts/network/player_net_input.gd.uid b/scripts/network/player_net_input.gd.uid new file mode 100644 index 0000000..91b3487 --- /dev/null +++ b/scripts/network/player_net_input.gd.uid @@ -0,0 +1 @@ +uid://syopmnypsnfo