Files
Papay-Shooter/globals/network_manager.gd
T
Nicholas ButzkeandClaude Opus 5 986179854d feat(modes): matches now end, and something is at stake when they do
There was a string — `current_gamemode`, always "Deathmatch" — an OptionButton
with one entry in it, and a five-minute timer that counted to zero, set
`match_active = false`, and did nothing else. No winner was declared, no summary
appeared, the clock froze at 00:00, and players carried on shooting each other
in a match that had stopped counting. Nothing was tracked beyond a running kill
count. A match did not end so much as stop mattering, and everything a player
does in the last minute only matters if there is a last minute.

globals/game_mode.gd answers the three questions a mode has to answer — how you
score, when it ends, who won — for three modes:

  Deathmatch        25 frags or 10 minutes.
  Team Deathmatch   two squads to 50, teams balanced by COUNT on join (a 4v4
                    that loses three from one side must refill the short side;
                    round-robin on join order leaves it 4v1 forever), friendly
                    fire off and enforced on the server before damage is even
                    broadcast — a mode where the damage lands but the kill does
                    not count is worse than either.
  Gun Game          every kill promotes you a rung and swaps your weapon. The
                    score IS the rung and the limit IS the ladder's length, so
                    finishing the ladder and reaching the score limit are the
                    same event and only one win condition exists.

`check_win` is a pure function of the stats and the clock, deliberately, because
a win condition that can only be exercised by playing a whole match is one
nobody tests — and the previous one never was. debug/game_mode_check.gd runs 30
cases over it: a tie is a DRAW rather than a win for whoever came first out of
the dictionary; a team match is decided on the TEAM's total, which a per-player
check never reaches; every ladder rung names a weapon that exists, or that rung
softlocks the mode; and promoting past the top clamps, because the winning kill
promotes before the match-end RPC lands.

Tracking now covers score, team, current streak, best streak and ladder rung.
Score and kills are separate numbers because in Gun Game they coincide and in
anything with an objective they would not. The scoreboard ranks by score, shows
the mode's own noun for it, and puts the limit on the top line — a win condition
players cannot see is one they cannot play toward.

ui/match_summary.gd gives the ending somewhere to happen: who won, WHY (time and
frag limit are different stories about the same scoreline), team totals, full
standings, and a way out that is not alt-F4. Play Again is host-only on a server.

Three bugs found on the way, all of which only became bugs once matches could
actually end: loading a level reset the clock but not the scores, so the second
match on a server would have ended on its first kill; `.rpc()` on an offline
peer does not call locally, so singleplayer had no killfeed and no stat sync at
all; and a rocket already in the air at the whistle could change the result
after the summary was on screen.

spawn smoke 0, game modes 30/30, movement 11/11.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:28:32 -04:00

425 lines
16 KiB
GDScript

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)
current_gamemode = gamemode
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))