extends Node signal player_connected(id: int) signal player_disconnected(id: int) signal connection_failed signal connection_succeeded signal match_state_updated signal stats_updated signal killfeed_event(victim: String, killer: String, weapon: String, v_color: String, k_color: String) const DEFAULT_PORT = 31415 var connected_players: Array[int] = [] # Dict of ID -> { "username": String, "kills": int, "deaths": int, "assists": int, "ping": int } var player_stats: Dictionary = {} var match_active: bool = false var match_time_remaining: float = 0.0 var current_gamemode: String = "Deathmatch" var current_scene_path: String = "" var _ping_timer: Timer func _ready() -> void: multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_disconnected.connect(_on_peer_disconnected) multiplayer.connected_to_server.connect(_on_connected_to_server) multiplayer.connection_failed.connect(_on_connection_failed) multiplayer.server_disconnected.connect(_on_server_disconnected) _ping_timer = Timer.new() _ping_timer.wait_time = 1.0 _ping_timer.autostart = false _ping_timer.timeout.connect(_on_ping_timer) add_child(_ping_timer) func host_game(port: int = DEFAULT_PORT) -> Error: var peer = ENetMultiplayerPeer.new() var err = peer.create_server(port) if err == OK: multiplayer.multiplayer_peer = peer connected_players.clear() connected_players.append(1) # Host is 1 _init_player_stats(1, SettingsManager.username) _ping_timer.start() print("Hosting on port %s" % port) return err func join_game(ip: String, port: int = DEFAULT_PORT) -> Error: var peer = ENetMultiplayerPeer.new() var err = peer.create_client(ip, port) if err == OK: multiplayer.multiplayer_peer = peer print("Connecting to %s:%s" % [ip, port]) return err func disconnect_game() -> void: multiplayer.multiplayer_peer = null connected_players.clear() player_stats.clear() match_active = false _ping_timer.stop() func start_singleplayer_match(gamemode: String = "Deathmatch") -> void: # Ensure peer is offline if not already connected if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer: multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() connected_players.clear() connected_players.append(1) _init_player_stats(1, SettingsManager.username) match_active = true match_time_remaining = 300.0 current_gamemode = gamemode rpc("sync_match_state", match_active, match_time_remaining, current_gamemode) func _init_player_stats(id: int, username: String) -> void: # Keep existing color if player re-joins, or generate new var p_color = Color(randf_range(0.2, 1.0), randf_range(0.2, 1.0), randf_range(0.2, 1.0)).to_html(false) player_stats[id] = { "username": username, "kills": 0, "deaths": 0, "assists": 0, "ping": 0, "color": p_color } stats_updated.emit() func _on_peer_connected(id: int) -> void: print("Player connected: ", id) if not connected_players.has(id): connected_players.append(id) # If server, we wait for the client to register themselves via RPC pass func _on_peer_disconnected(id: int) -> void: print("Player disconnected: ", id) connected_players.erase(id) if player_stats.has(id): player_stats.erase(id) player_disconnected.emit(id) if multiplayer.is_server(): rpc("sync_player_stats", player_stats) func _on_connected_to_server() -> void: print("Successfully connected to server") connection_succeeded.emit() # Tell the server our username rpc_id(1, "register_player", SettingsManager.username) func _on_connection_failed() -> void: print("Failed to connect to server") multiplayer.multiplayer_peer = null connection_failed.emit() func _on_server_disconnected() -> void: print("Server disconnected") multiplayer.multiplayer_peer = null connected_players.clear() player_stats.clear() match_active = false @rpc("any_peer", "call_remote", "reliable") func register_player(username: String) -> void: if multiplayer.is_server(): var sender_id = multiplayer.get_remote_sender_id() _init_player_stats(sender_id, username) player_connected.emit(sender_id) # Sync everyone's stats rpc("sync_player_stats", player_stats) # If a match is active, force the late-joiner to load the level if match_active: rpc_id(sender_id, "rpc_load_level", current_scene_path) rpc_id(sender_id, "sync_match_state", match_active, match_time_remaining, current_gamemode) @rpc("any_peer", "call_local", "reliable") func update_username(new_name: String) -> void: if multiplayer.is_server() or not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer: var sender_id = multiplayer.get_remote_sender_id() if sender_id == 0: sender_id = 1 if player_stats.has(sender_id): player_stats[sender_id]["username"] = new_name stats_updated.emit() if multiplayer.has_multiplayer_peer() and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer: rpc("sync_player_stats", player_stats) @rpc("authority", "call_local", "reliable") func sync_player_stats(stats: Dictionary) -> void: player_stats = stats stats_updated.emit() @rpc("authority", "call_local", "reliable") func sync_match_state(active: bool, time_rem: float, mode: String) -> void: match_active = active match_time_remaining = time_rem current_gamemode = mode match_state_updated.emit() @rpc("authority", "call_local", "reliable") func rpc_load_level(scene_path: String) -> void: print("Loading level: ", scene_path) current_scene_path = scene_path if multiplayer.is_server(): match_active = true match_time_remaining = 300.0 # 5 minutes rpc("sync_match_state", match_active, match_time_remaining, current_gamemode) get_tree().change_scene_to_file(scene_path) func _process(delta: float) -> void: if match_active: if match_time_remaining > 0: match_time_remaining -= delta if match_time_remaining <= 0: match_time_remaining = 0 match_active = false # We emit this so HUDs can update their timers smoothly match_state_updated.emit() # --- PING SYSTEM --- func _on_ping_timer() -> void: if multiplayer.is_server(): var time = Time.get_ticks_msec() for pid in connected_players: if pid != 1: rpc_id(pid, "receive_ping_request", time) rpc("sync_player_stats", player_stats) @rpc("authority", "call_remote", "unreliable") func receive_ping_request(server_time: int) -> void: rpc_id(1, "receive_ping_response", server_time) @rpc("any_peer", "call_remote", "unreliable") func receive_ping_response(server_time: int) -> void: if multiplayer.is_server(): var sender_id = multiplayer.get_remote_sender_id() var rtt = Time.get_ticks_msec() - server_time if player_stats.has(sender_id): player_stats[sender_id]["ping"] = rtt # --- GAMEPLAY EVENTS --- @rpc("any_peer", "call_local", "reliable") func register_kill(victim_id: int, killer_id: int, weapon_name: String, custom_victim_name: String = "", assist_ids: Array = []) -> void: if multiplayer.is_server(): if killer_id != 0 and killer_id != victim_id: if player_stats.has(killer_id): player_stats[killer_id]["kills"] += 1 if victim_id != -1: if player_stats.has(victim_id): player_stats[victim_id]["deaths"] += 1 for aid in assist_ids: if player_stats.has(aid) and aid != killer_id and aid != victim_id: player_stats[aid]["assists"] += 1 var v_name = player_stats[victim_id]["username"] if player_stats.has(victim_id) else (custom_victim_name if custom_victim_name != "" else "Unknown") var k_name = player_stats[killer_id]["username"] if player_stats.has(killer_id) else "Unknown" var v_color = player_stats[victim_id].get("color", "cccccc") if player_stats.has(victim_id) else "cccccc" var k_color = player_stats[killer_id].get("color", "cccccc") if player_stats.has(killer_id) else "cccccc" # Auto-fallback for singleplayer testing where player_stats might not be initialized if not player_stats.has(killer_id) and killer_id == 1: k_name = SettingsManager.username k_color = "55aaff" # Default blueish for uninitialized local player if victim_id == -1 and "Dummy" in custom_victim_name: v_color = "ff5555" # Red for dummies if killer_id == 0 or killer_id == victim_id: k_name = "" # Suicide or world death broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color) sync_player_stats.rpc(player_stats) @rpc("authority", "call_local", "reliable") func broadcast_killfeed(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void: killfeed_event.emit(victim, killer, weapon, v_color, k_color)