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) ## The match is over. `result` carries the reason ("score" / "time"), the winner's ## name, and the final standings. The HUD raises the summary screen on this. signal match_ended(result: Dictionary) ## A Gun Game player moved up a rung and needs their weapon swapped. signal ladder_promoted(peer_id: int, rung: int, weapon_id: String) const DEFAULT_PORT = 31415 var connected_players: Array[int] = [] ## ID -> { username, kills, deaths, assists, ping, color, score, team, streak, ## best_streak, rung } ## ## `score` is what the MODE counts and is what decides the match — in Deathmatch ## it tracks kills, in Gun Game it is the ladder rung. `kills` stays a plain ## kill count regardless, because the scoreboard shows both and they are not the ## same number in every mode. var player_stats: Dictionary = {} var match_active: bool = false var match_time_remaining: float = 0.0 ## A GameMode id — see globals/game_mode.gd. Was a display string ("Deathmatch") ## compared by equality in three places; an id is what the mode table is keyed on ## and what survives being sent over the wire. var current_gamemode: String = GameMode.DEATHMATCH var current_scene_path: String = "" ## Set when the match ends, so a late-arriving HUD can still show the summary. var last_result: Dictionary = {} 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 = GameMode.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) # Normalised, because `current_gamemode` used to be a DISPLAY string # ("Deathmatch") and half a dozen callers still pass one. GameMode's lookups # all fall back safely, but an id that is not in the table would compare # unequal to GameMode.GUN_GAME and silently disable Gun Game's weapon # issuing — a mode that half works is worse than one that does not load. current_gamemode = gamemode if GameMode.MODES.has(gamemode) \ else GameMode.DEATHMATCH player_stats.clear() _init_player_stats(1, SettingsManager.username) last_result = {} match_active = true # The mode's own clock, not a hardcoded five minutes. Gun Game is a fifteen # minute mode and Deathmatch a ten minute one; both used to be five. match_time_remaining = GameMode.time_limit(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) var team := GameMode.assign_team(current_gamemode, player_stats) if team > 0: # In a team mode the player's colour IS their team's, because a killfeed # where a teammate and an enemy are both "some random colour" is a # killfeed that cannot be read at a glance. p_color = GameMode.team_color(team).to_html(false) player_stats[id] = { "username": username, "kills": 0, "deaths": 0, "assists": 0, "ping": 0, "color": p_color, # What the MODE counts. See the note on player_stats. "score": 0, "team": team, "streak": 0, "best_streak": 0, # Gun Game ladder position. "rung": 0, } 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(): # Reset the scores as well as the clock. Loading a level used to restart # the timer and leave every kill from the previous match standing, so the # second match on a server started with someone already at the frag limit # — and now that reaching the limit ENDS the match, it would have ended # on the first kill. last_result = {} for pid in player_stats: for key in ["kills", "deaths", "assists", "score", "streak", "best_streak", "rung"]: player_stats[pid][key] = 0 match_active = true match_time_remaining = GameMode.time_limit(current_gamemode) rpc("sync_player_stats", player_stats) 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 not match_active: return if match_time_remaining > 0: match_time_remaining -= delta if match_time_remaining <= 0: match_time_remaining = 0 # Time is a win CONDITION, not an off switch. This used to set # `match_active = false` and stop, so the clock reached zero, the # timer froze at 00:00 and nothing else happened — no winner, no # summary, no way back to the menu. The match did not end so much as # stop mattering. if _is_authority(): _check_win() # We emit this so HUDs can update their timers smoothly match_state_updated.emit() ## Whether this peer decides the match. True for the server, and true in ## singleplayer, where there is no peer but somebody still has to call it. func _is_authority() -> bool: if not multiplayer.has_multiplayer_peer(): return true if multiplayer.multiplayer_peer is OfflineMultiplayerPeer: return true return multiplayer.is_server() ## Has anyone won? Called on the server after every kill and when the clock runs ## out. GameMode.check_win is a pure function of the stats, so the rule lives ## next to the mode that defines it and can be tested without a match running. func _check_win() -> void: if not match_active: return var result := GameMode.check_win(current_gamemode, player_stats, match_time_remaining) if result.is_empty(): return result["mode"] = current_gamemode result["standings"] = GameMode.standings(player_stats) result["stats"] = player_stats.duplicate(true) if _is_authority() and multiplayer.has_multiplayer_peer() \ and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer: rpc("end_match", result) else: end_match(result) @rpc("authority", "call_local", "reliable") func end_match(result: Dictionary) -> void: if not match_active: return match_active = false last_result = result match_state_updated.emit() match_ended.emit(result) ## Wipe the scores and start the clock again on the same map and mode. The ## summary screen's "Play Again". @rpc("authority", "call_local", "reliable") func restart_match() -> void: for pid in player_stats: for key in ["kills", "deaths", "assists", "score", "streak", "best_streak", "rung"]: player_stats[pid][key] = 0 last_result = {} match_active = true match_time_remaining = GameMode.time_limit(current_gamemode) stats_updated.emit() 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 _is_authority(): # A kill after the final whistle counts for nothing. Without this a # rocket already in the air when the clock hit zero could change the # result after the summary was on screen. if not match_active: return var clean_kill := killer_id != 0 and killer_id != victim_id # A team kill is not a score. Friendly fire may be ON in a mode and the # kill still must not advance the killer — otherwise the fastest way to # win Team Deathmatch is to shoot your own team. if clean_kill and GameMode.is_team_mode(current_gamemode) \ and player_stats.has(killer_id) and player_stats.has(victim_id) \ and player_stats[killer_id]["team"] == player_stats[victim_id]["team"]: clean_kill = false if clean_kill and player_stats.has(killer_id): var ks: Dictionary = player_stats[killer_id] ks["kills"] += 1 ks["streak"] += 1 ks["best_streak"] = maxi(ks["best_streak"], ks["streak"]) if current_gamemode == GameMode.GUN_GAME: # A kill is a RUNG, not a point, and the rung IS the score — so # reaching the top of the ladder is the same event as reaching # the score limit, and only one win condition has to exist. ks["rung"] += 1 ks["score"] = ks["rung"] var weapon := GameMode.ladder_weapon(ks["rung"]) ladder_promoted.emit(killer_id, ks["rung"], weapon) rpc("notify_promotion", killer_id, ks["rung"], weapon) else: ks["score"] += int(GameMode.get_mode(current_gamemode).get( "kill_score", 1)) if victim_id != -1: if player_stats.has(victim_id): player_stats[victim_id]["deaths"] += 1 player_stats[victim_id]["streak"] = 0 var penalty := int(GameMode.get_mode(current_gamemode).get( "death_score", 0)) if penalty != 0: player_stats[victim_id]["score"] = maxi( player_stats[victim_id]["score"] - penalty, 0) 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 # `.rpc()` on an offline peer does not call locally, so singleplayer got # no killfeed and no stat sync at all. Both paths now go through the # same two calls. if multiplayer.has_multiplayer_peer() \ and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer: broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color) sync_player_stats.rpc(player_stats) else: broadcast_killfeed(v_name, k_name, weapon_name, v_color, k_color) sync_player_stats(player_stats) # The scores just changed, so this is exactly when a match can end. _check_win() @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) ## A Gun Game player moved up. Broadcast so every client's killfeed can say so ## and the promoted player's own client can swap their weapon. @rpc("authority", "call_local", "reliable") func notify_promotion(peer_id: int, rung: int, weapon_id: String) -> void: ladder_promoted.emit(peer_id, rung, weapon_id) ## Whether `a` may damage `b`, per the mode. The only place friendly fire is ## decided, so a mode that turns it off turns it off everywhere. func can_damage(attacker_id: int, victim_id: int) -> bool: if attacker_id == victim_id: return true if GameMode.friendly_fire(current_gamemode): return true if not player_stats.has(attacker_id) or not player_stats.has(victim_id): return true var ta: int = int(player_stats[attacker_id].get("team", 0)) var tb: int = int(player_stats[victim_id].get("team", 0)) return ta == 0 or ta != tb ## Which team a peer is on, 0 for free-for-all. func team_of(peer_id: int) -> int: return int(player_stats.get(peer_id, {}).get("team", 0))