Feat/fidelity pass 2 #23

Merged
Dotts merged 7 commits from feat/fidelity-pass-2 into main 2026-07-28 10:31:27 -07:00
12 changed files with 1030 additions and 76 deletions
Showing only changes of commit 986179854d - Show all commits
+1
View File
@@ -0,0 +1 @@
uid://tds0trjfr2as
+1
View File
@@ -0,0 +1 @@
uid://cxxajtpm3o8bj
+157
View File
@@ -0,0 +1,157 @@
extends SceneTree
## Do the game modes end, and end on the RIGHT thing?
##
## godot --headless --path . -s res://debug/game_mode_check.gd
##
## `GameMode.check_win` is a pure function of the stats and the clock, which is
## the point of writing it that way: a win condition that can only be exercised
## by playing a whole match is a win condition nobody tests, and this project's
## previous one — a five-minute timer that set `match_active = false` and did
## nothing else — was never exercised at all.
##
## The cases below are the ones that actually go wrong: a draw reported as a win
## for whoever happens to be first in a dictionary, a team mode decided on one
## player's score instead of the team's, and a team kill counting toward the
## killer.
var _fails := 0
func _init() -> void:
# Autoloads are not up during `_init`; LoadoutManager is needed below.
await process_frame
_check_table()
_check_deathmatch()
_check_team()
_check_gun_game()
_check_standings()
_done()
## Every mode must be able to end. A mode with no score limit and no time limit
## runs forever, and that is the failure that produced this whole task.
func _check_table() -> void:
for id in GameMode.all_ids():
_expect(GameMode.is_valid(id),
"'%s' can end (score %d, time %.0fs)"
% [id, GameMode.score_limit(id), GameMode.time_limit(id)])
_expect(GameMode.display_name(id) != "",
"'%s' has a display name" % id)
_expect(GameMode.score_limit(GameMode.GUN_GAME) == GameMode.LADDER.size(),
"Gun Game's limit IS its ladder length (%d), so adding a rung cannot make the match unwinnable"
% GameMode.LADDER.size())
func _check_deathmatch() -> void:
var id := GameMode.DEATHMATCH
var limit := GameMode.score_limit(id)
var mid := {1: _p("A", limit - 1), 2: _p("B", 3)}
_expect(GameMode.check_win(id, mid, 120.0).is_empty(),
"deathmatch keeps running one short of the limit")
var done := {1: _p("A", limit), 2: _p("B", 3)}
var r := GameMode.check_win(id, done, 120.0)
_expect(r.get("reason", "") == "score" and r.get("name", "") == "A",
"deathmatch ends when someone reaches %d, and A won" % limit)
# On time, the leader takes it.
var timed := GameMode.check_win(id, {1: _p("A", 7), 2: _p("B", 4)}, 0.0)
_expect(timed.get("reason", "") == "time" and timed.get("name", "") == "A",
"deathmatch on time goes to the leader")
# A DRAW is a draw. Reporting a winner here means reporting whichever key
# the dictionary happened to hand over first, which is not a rule.
var drawn := GameMode.check_win(id, {1: _p("A", 5), 2: _p("B", 5)}, 0.0)
_expect(drawn.get("name", "") == "DRAW",
"a tie on time is a DRAW, not a win for whoever is first in the dictionary")
func _check_team() -> void:
var id := GameMode.TEAM_DEATHMATCH
var limit := GameMode.score_limit(id)
# Two on team 1 with half the limit each: the TEAM has won, though neither
# player is close on their own. Deciding this on individual score — which is
# what a per-player check would do — never ends a team match.
var half := int(limit / 2.0)
var stats := {
1: _p("A", half, 1), 2: _p("B", half, 1),
3: _p("C", 4, 2), 4: _p("D", 4, 2),
}
var r := GameMode.check_win(id, stats, 300.0)
_expect(r.get("reason", "") == "score" and r.get("team", 0) == 1,
"team deathmatch is decided by the TEAM's total, not one player's")
var totals := GameMode.team_scores(id, stats)
_expect(totals.get(1, 0) == half * 2 and totals.get(2, 0) == 8,
"team totals add up (%d / %d)" % [totals.get(1, 0), totals.get(2, 0)])
# Balance on join, by count and not by turn — a 4v4 that loses three from one
# side must refill the short side, which round-robin on join order does not.
var lopsided := {1: _p("A", 0, 1), 2: _p("B", 0, 1), 3: _p("C", 0, 1)}
_expect(GameMode.assign_team(id, lopsided) == 2,
"a joining player goes to the SHORT team")
var tied := GameMode.check_win(id, {1: _p("A", 5, 1), 2: _p("B", 5, 2)}, 0.0)
_expect(tied.get("name", "") == "DRAW", "level teams on time is a DRAW")
func _check_gun_game() -> void:
var id := GameMode.GUN_GAME
var top := GameMode.LADDER.size()
_expect(GameMode.check_win(id, {1: _p("A", top - 1)}, 300.0).is_empty(),
"gun game keeps running on the last rung")
var r := GameMode.check_win(id, {1: _p("A", top)}, 300.0)
_expect(r.get("reason", "") == "score",
"gun game ends when someone finishes the ladder")
# Every rung must name a weapon that exists, or a promotion hands the player
# nothing and the mode softlocks on that rung.
#
# Reached through the tree rather than by the autoload's global identifier: a
# `-s` tool script compiles its dependencies BEFORE autoloads register, so
# naming `LoadoutManager` directly stops this file from loading at all.
var loadouts = root.get_node_or_null("LoadoutManager")
for i in GameMode.LADDER.size():
var w := GameMode.ladder_weapon(i)
_expect(loadouts != null and loadouts.weapon_db.has(w),
"ladder rung %d ('%s') is a real weapon" % [i, w])
# Past the end clamps rather than going out of bounds — the winning kill
# promotes past the top rung before the match-end RPC lands.
_expect(GameMode.ladder_weapon(999) == GameMode.LADDER[-1],
"promoting past the top rung clamps instead of erroring")
func _check_standings() -> void:
var stats := {
1: _p("A", 5), 2: _p("B", 9), 3: _p("C", 5),
}
stats[1]["deaths"] = 2
stats[3]["deaths"] = 7
var order := GameMode.standings(stats)
_expect(order[0] == 2, "standings put the highest score first")
_expect(order[1] == 1 and order[2] == 3,
"equal scores break on FEWEST DEATHS (kills are equal by definition there)")
func _p(who: String, score: int, team: int = 0) -> Dictionary:
return {"username": who, "score": score, "kills": score, "deaths": 0,
"assists": 0, "team": team, "best_streak": 0, "rung": score}
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: ", what)
else:
print(" FAIL: ", what)
_fails += 1
func _done() -> void:
print("\n=== GAME MODE SUMMARY ===")
print("Failures: %d" % _fails)
quit(1 if _fails > 0 else 0)
+71
View File
@@ -0,0 +1,71 @@
extends SceneTree
## Photograph the end-of-match summary, for a free-for-all and for a team mode.
##
## godot --path . --windowed --resolution 1280x800 \
## -s res://debug/summary_capture.gd -- <out_dir>
##
## Fed a synthetic result rather than a played match, so the screen can be looked
## at without spending ten minutes reaching a frag limit — and so the awkward
## cases (a draw, a long name, a team scoreline) can be put on screen on purpose
## instead of waiting for them to happen.
var _out := "."
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out = String(args[0])
_run()
func _run() -> void:
await process_frame
UITheme.apply_global(self)
var layer := CanvasLayer.new()
root.add_child(layer)
var back := ColorRect.new()
back.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
back.color = Color(0.30, 0.42, 0.62)
layer.add_child(back)
var summary := MatchSummary.new()
layer.add_child(summary)
await _shot(summary, "ffa", {
"reason": "score", "name": "Nicholas", "mode": GameMode.DEATHMATCH,
"standings": [1, 2, 3],
"stats": {
1: _p("Nicholas", 25, 25, 11, 4, 7),
2: _p("Papaya Enjoyer", 19, 19, 17, 2, 4),
3: _p("guest_4471", 8, 8, 24, 9, 2),
}})
await _shot(summary, "team", {
"reason": "time", "team": 2, "name": "MAGENTA TEAM",
"mode": GameMode.TEAM_DEATHMATCH,
"standings": [3, 1, 4, 2],
"stats": {
1: _p("Nicholas", 14, 14, 9, 3, 5, 1),
2: _p("Papaya Enjoyer", 6, 6, 15, 1, 2, 1),
3: _p("guest_4471", 17, 17, 8, 6, 6, 2),
4: _p("bananaboat", 12, 12, 10, 4, 3, 2),
}})
quit(0)
func _shot(summary: MatchSummary, tag: String, result: Dictionary) -> void:
summary.show_result(result)
for _i in 6:
await process_frame
root.get_texture().get_image().save_png("%s/summary_%s.png" % [_out, tag])
print("summary_capture: saved ", tag)
func _p(who: String, score: int, kills: int, deaths: int, assists: int,
streak: int, team: int = 0) -> Dictionary:
return {"username": who, "score": score, "kills": kills, "deaths": deaths,
"assists": assists, "best_streak": streak, "team": team}
+265
View File
@@ -0,0 +1,265 @@
extends Object
class_name GameMode
## What a match IS: how you score, when it ends, and who won.
##
## Before this there was a string — `NetworkManager.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 ever declared, no summary was ever shown, and nothing was tracked
## beyond a running kill count. A match did not end so much as stop mattering.
##
## The three things a mode has to answer are answered here:
##
## HOW YOU SCORE what a kill is worth, and to whom. In Gun Game a kill is
## worth a rung on a ladder rather than a point.
## WHEN IT ENDS a score limit, a time limit, or both, whichever comes
## first. A mode with neither cannot end and is not a mode.
## WHO WON the top player, or the top TEAM, which are different
## questions and used to be the same one.
##
## Deliberately a plain data table with static helpers rather than an autoload:
## the menu, the lobby, the HUD and the server all need to ask what a mode is,
## and half of them run before any autoload would be ready.
const DEATHMATCH := "deathmatch"
const TEAM_DEATHMATCH := "team_deathmatch"
const GUN_GAME := "gun_game"
## The order they appear in menus. Deathmatch first because it is the one that
## needs no explanation.
const ORDER := [DEATHMATCH, TEAM_DEATHMATCH, GUN_GAME]
## `score_limit` of 0 means the mode has no score limit and ends on time alone.
## `time_limit` is in seconds; 0 means it ends on score alone. At least one of
## the two must be non-zero or the match cannot end — `is_valid` checks that.
const MODES := {
DEATHMATCH: {
"name": "Deathmatch",
"blurb": "Everyone for themselves. First to the frag limit takes it.",
"score_limit": 25,
"time_limit": 600.0,
"teams": 0,
"friendly_fire": true,
"score_noun": "KILLS",
# What one kill is worth to the killer.
"kill_score": 1,
# What dying costs. Zero: a deathmatch that punishes dying rewards
# hiding, and this is a game about momentum.
"death_score": 0,
},
TEAM_DEATHMATCH: {
"name": "Team Deathmatch",
"blurb": "Two squads. Team score is what counts.",
"score_limit": 50,
"time_limit": 600.0,
"teams": 2,
"friendly_fire": false,
"score_noun": "SCORE",
"kill_score": 1,
"death_score": 0,
},
GUN_GAME: {
"name": "Gun Game",
"blurb": "Every kill promotes you to the next weapon. Finish the ladder.",
# The limit IS the ladder length, filled in from LADDER below so the two
# can never disagree.
"score_limit": 0,
"time_limit": 900.0,
"teams": 0,
"friendly_fire": true,
"score_noun": "RUNG",
"kill_score": 1,
"death_score": 0,
},
}
## Gun Game's ladder, by weapon id (see LoadoutManager.weapon_db). Ordered
## roughly easiest to hardest to get a kill with, so the last rung is a real
## finish rather than a formality.
const LADDER := ["ak47", "m4", "mp7", "dmr", "plasma_gun", "nail_gun",
"double_barrel_shotgun", "rocket_launcher", "awp", "knife"]
static func all_ids() -> Array:
return ORDER.duplicate()
static func get_mode(id: String) -> Dictionary:
return MODES.get(id, MODES[DEATHMATCH])
static func display_name(id: String) -> String:
return String(get_mode(id).get("name", id))
static func blurb(id: String) -> String:
return String(get_mode(id).get("blurb", ""))
## The score that ends the match. For Gun Game this is the ladder's length, so
## adding a weapon to the ladder cannot leave the win condition unreachable.
static func score_limit(id: String) -> int:
if id == GUN_GAME:
return LADDER.size()
return int(get_mode(id).get("score_limit", 0))
static func time_limit(id: String) -> float:
return float(get_mode(id).get("time_limit", 0.0))
static func team_count(id: String) -> int:
return int(get_mode(id).get("teams", 0))
static func is_team_mode(id: String) -> bool:
return team_count(id) > 1
static func friendly_fire(id: String) -> bool:
return bool(get_mode(id).get("friendly_fire", true))
static func score_noun(id: String) -> String:
return String(get_mode(id).get("score_noun", "SCORE"))
## A mode with neither a score limit nor a time limit can never end. This is the
## one invariant worth asserting about the table itself, and debug/game_mode_check
## runs it over every entry.
static func is_valid(id: String) -> bool:
return score_limit(id) > 0 or time_limit(id) > 0.0
## The weapon a player on `rung` of the Gun Game ladder is holding.
static func ladder_weapon(rung: int) -> String:
if LADDER.is_empty():
return ""
return LADDER[clampi(rung, 0, LADDER.size() - 1)]
## Team colours, for the scoreboard, the killfeed and the HUD. Index 0 is no
## team — free-for-all — which reads as the game's own papaya.
const TEAM_COLORS := [Color(1.00, 0.47, 0.10), Color(0.22, 0.94, 1.00),
Color(1.00, 0.18, 0.52)]
const TEAM_NAMES := ["", "CYAN", "MAGENTA"]
static func team_color(team: int) -> Color:
return TEAM_COLORS[clampi(team, 0, TEAM_COLORS.size() - 1)]
static func team_name(team: int) -> String:
if team <= 0 or team >= TEAM_NAMES.size():
return ""
return TEAM_NAMES[team]
## Which team a joining player goes on: the smallest, ties broken toward team 1.
##
## Assigned by COUNT rather than round-robin on join order, because players
## leave. Round-robin on a 4v4 that loses three from one side stays 4v1 forever;
## this refills the short side.
static func assign_team(id: String, stats: Dictionary) -> int:
var teams := team_count(id)
if teams < 2:
return 0
var counts := []
counts.resize(teams + 1)
counts.fill(0)
for pid in stats:
var t: int = int(stats[pid].get("team", 0))
if t >= 1 and t <= teams:
counts[t] += 1
var best := 1
for t in range(1, teams + 1):
if counts[t] < counts[best]:
best = t
return best
## Every team's total, as `team index -> score`. Team 0 is never included.
static func team_scores(id: String, stats: Dictionary) -> Dictionary:
var out := {}
if not is_team_mode(id):
return out
for t in range(1, team_count(id) + 1):
out[t] = 0
for pid in stats:
var t: int = int(stats[pid].get("team", 0))
if out.has(t):
out[t] += int(stats[pid].get("score", 0))
return out
## Has anyone won yet? Returns `{}` if not, else the reason and the winner.
##
## Server-side only — this is the authority on when a match stops. It is a pure
## function of the stats so it can be unit-tested without a match running, which
## debug/game_mode_check.gd does.
static func check_win(id: String, stats: Dictionary, time_left: float) -> Dictionary:
var limit := score_limit(id)
if is_team_mode(id):
var totals := team_scores(id, stats)
if limit > 0:
for t in totals:
if totals[t] >= limit:
return {"reason": "score", "team": t,
"name": team_name(t) + " TEAM"}
if time_left <= 0.0:
var best := 0
var best_score := -1
var tied := false
for t in totals:
if totals[t] > best_score:
best_score = totals[t]
best = t
tied = false
elif totals[t] == best_score:
tied = true
if tied:
return {"reason": "time", "team": 0, "name": "DRAW"}
return {"reason": "time", "team": best,
"name": team_name(best) + " TEAM"}
return {}
# Free-for-all.
if limit > 0:
for pid in stats:
if int(stats[pid].get("score", 0)) >= limit:
return {"reason": "score", "player": pid,
"name": String(stats[pid].get("username", "Player"))}
if time_left <= 0.0:
var top = null
var top_score := -1
var drawn := false
for pid in stats:
var sc: int = int(stats[pid].get("score", 0))
if sc > top_score:
top_score = sc
top = pid
drawn = false
elif sc == top_score:
drawn = true
if top == null or drawn:
return {"reason": "time", "player": 0, "name": "DRAW"}
return {"reason": "time", "player": top,
"name": String(stats[top].get("username", "Player"))}
return {}
## Players sorted best-first, by score then by fewest deaths.
##
## Fewest deaths as the tiebreak, not most kills: at equal score the kills are
## equal by definition in every mode here, so kills would not break anything.
static func standings(stats: Dictionary) -> Array:
var ids: Array = stats.keys()
ids.sort_custom(func(a, b):
var sa: int = int(stats[a].get("score", 0))
var sb: int = int(stats[b].get("score", 0))
if sa != sb:
return sa > sb
return int(stats[a].get("deaths", 0)) < int(stats[b].get("deaths", 0)))
return ids
+1
View File
@@ -0,0 +1 @@
uid://b8k2ha37f47ji
+191 -13
View File
@@ -7,18 +7,34 @@ 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] = []
# Dict of ID -> { "username": String, "kills": int, "deaths": int, "assists": int, "ping": 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
var current_gamemode: String = "Deathmatch"
## 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
@@ -62,23 +78,33 @@ func disconnect_game() -> void:
match_active = false
_ping_timer.stop()
func start_singleplayer_match(gamemode: String = "Deathmatch") -> void:
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
match_time_remaining = 300.0
current_gamemode = gamemode
# 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,
@@ -86,7 +112,14 @@ func _init_player_stats(id: int, username: String) -> void:
"deaths": 0,
"assists": 0,
"ping": 0,
"color": p_color
"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()
@@ -169,21 +202,94 @@ 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 = 300.0 # 5 minutes
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 match_active:
if not match_active:
return
if match_time_remaining > 0:
match_time_remaining -= delta
if match_time_remaining <= 0:
match_time_remaining = 0
match_active = false
# 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():
@@ -208,14 +314,49 @@ func receive_ping_response(server_time: int) -> void:
# --- 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 _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:
@@ -238,9 +379,46 @@ func register_kill(victim_id: int, killer_id: int, weapon_name: String, custom_v
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))
+10
View File
@@ -652,6 +652,16 @@ func rpc_play_explosion(pos: Vector3, radius: float) -> void:
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
if not multiplayer.is_server(): return
# Friendly fire, decided by the MODE and enforced here — on the server, before
# the damage is broadcast — so a mode that turns it off turns it off for real
# rather than merely declining to award the kill. Team Deathmatch with damage
# that lands and a kill that does not count is worse than either.
var nm = get_node_or_null("/root/NetworkManager")
if nm and nm.has_method("can_damage") and attacker_id != 0:
var victim_id := int(str(name)) if str(name).is_valid_int() else 0
if victim_id != 0 and not nm.can_damage(attacker_id, victim_id):
return
# Broadcast damage event to all peers so the victim dies on all screens.
# Knockback is applied inside rpc_take_damage on the victim's own peer,
# since that peer simulates this body.
+87 -39
View File
@@ -14,6 +14,35 @@ var _killfeed_vbox: VBoxContainer
var _scoreboard_panel: PanelContainer
var _scoreboard_grid: GridContainer
var _summary: MatchSummary
func _on_match_ended(result: Dictionary) -> void:
_scoreboard_panel.hide()
_summary.show_result(result)
func _on_play_again() -> void:
if not _nm:
return
# The host decides for everyone; a client's button is disabled.
if _nm.multiplayer.has_multiplayer_peer() \
and not _nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
if _nm.multiplayer.is_server():
_nm.restart_match.rpc()
else:
_nm.restart_match()
_summary.visible = false
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _on_to_menu() -> void:
if _nm:
_nm.match_active = false
_nm.disconnect_game()
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
get_tree().change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _ready() -> void:
_nm = get_node_or_null("/root/NetworkManager")
# Ensure the comic theme is active even when a level is loaded directly
@@ -98,12 +127,27 @@ func _ready() -> void:
_populate_scoreboard_headers()
# The end-of-match screen. Built once and hidden, rather than instantiated on
# the signal, so the moment the match ends nothing has to be loaded.
_summary = MatchSummary.new()
_summary.name = "MatchSummary"
_summary.visible = false
_summary.play_again.connect(_on_play_again)
_summary.to_menu.connect(_on_to_menu)
add_child(_summary)
# Signals
if _nm:
_nm.stats_updated.connect(_update_hud)
_nm.killfeed_event.connect(_on_killfeed_event)
_nm.match_state_updated.connect(_update_timer)
_nm.match_ended.connect(_on_match_ended)
_update_hud()
# A HUD that loads AFTER the match ended — a late joiner, or a scene
# reload — still has to show the result, so it asks rather than only
# listening. `last_result` is empty during a live match.
if not _nm.last_result.is_empty():
_on_match_ended(_nm.last_result)
func _process(_delta: float) -> void:
if Input.is_action_just_pressed("scoreboard"):
@@ -126,7 +170,11 @@ func _populate_scoreboard_headers() -> void:
for child in _scoreboard_grid.get_children():
child.queue_free()
var headers = ["Player", "Kills", "Deaths", "Assists", "Ping"]
# SCORE is first and is the mode's own noun, because it is what decides the
# match. Kills and score are the same number in Deathmatch and different in
# Gun Game, which is exactly why both are shown.
var noun := GameMode.score_noun(_nm.current_gamemode) if _nm else "SCORE"
var headers = ["Player", noun, "Kills", "Deaths", "Assists", "Streak", "Ping"]
for h in headers:
var l = Label.new()
l.text = h
@@ -138,59 +186,59 @@ func _update_hud() -> void:
if not _nm: return
var my_id = multiplayer.get_unique_id()
var leader_name = "None"
var max_kills = -1
var mode: String = _nm.current_gamemode
var noun := GameMode.score_noun(mode)
var stats = _nm.player_stats
# Sort players by kills for scoreboard
var players = stats.keys()
players.sort_custom(func(a, b): return stats[a].get("kills", 0) > stats[b].get("kills", 0))
# Ranked by SCORE, which is what the mode counts — not by kills, which in
# Gun Game is the same number by coincidence and in a mode with objectives
# would not be.
var players := GameMode.standings(stats)
_populate_scoreboard_headers()
for pid in players:
var p_data = stats[pid]
var p_kills = p_data.get("kills", 0)
var p_score = p_data.get("score", p_data.get("kills", 0))
# Find leader
if p_kills > max_kills:
max_kills = p_kills
leader_name = p_data.get("username", "Player " + str(pid))
# Update local kills
if pid == my_id:
_local_kills_label.text = str(p_kills) + " Kills"
_local_kills_label.text = "%s %s" % [p_score, noun]
var row_color := Color(p_data.get("color", "cccccc"))
if int(p_data.get("team", 0)) > 0:
row_color = GameMode.team_color(int(p_data.get("team", 0)))
# Build Scoreboard Row
var c_name = Label.new()
c_name.text = p_data.get("username", "Player")
c_name.add_theme_font_size_override("font_size", 24)
c_name.add_theme_color_override("font_color", Color(p_data.get("color", "cccccc")))
c_name.add_theme_color_override("font_color", row_color)
_scoreboard_grid.add_child(c_name)
var c_k = Label.new()
c_k.text = str(p_kills)
c_k.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_k)
for value in [str(p_score), str(p_data.get("kills", 0)),
str(p_data.get("deaths", 0)), str(p_data.get("assists", 0)),
"x%d" % int(p_data.get("best_streak", 0)),
"%dms" % int(p_data.get("ping", 0))]:
var cell = Label.new()
cell.text = value
cell.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(cell)
var c_d = Label.new()
c_d.text = str(p_data.get("deaths", 0))
c_d.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_d)
var c_a = Label.new()
c_a.text = str(p_data.get("assists", 0))
c_a.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_a)
var c_p = Label.new()
c_p.text = str(p_data.get("ping", 0)) + "ms"
c_p.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_p)
_leader_label.text = "Leader: %s (%s Kills)" % [leader_name, max_kills]
# The top line: who is leading, and how far there is to go. A score limit
# nobody can see is a win condition players cannot play toward.
var limit := GameMode.score_limit(mode)
if GameMode.is_team_mode(mode):
var totals := GameMode.team_scores(mode, stats)
var parts: PackedStringArray = []
for t in totals:
parts.append("%s %d" % [GameMode.team_name(t), totals[t]])
_leader_label.text = " / ".join(parts) + (" — to %d" % limit if limit > 0 else "")
elif players.is_empty():
_leader_label.text = "Leader: None"
else:
var top = players[0]
_leader_label.text = "Leader: %s (%s%s)" % [
stats[top].get("username", "Player " + str(top)),
stats[top].get("score", 0),
"/%d" % limit if limit > 0 else ""]
func _on_killfeed_event(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void:
var l = RichTextLabel.new()
+169
View File
@@ -0,0 +1,169 @@
extends Control
class_name MatchSummary
## The end of a match: who won, why, and what everyone did.
##
## There was nothing here before. The clock reached zero, `match_active` went
## false, the timer froze at 00:00, and the players kept shooting each other in a
## match that had stopped counting. A mode without an ending is a mode without
## stakes — everything a player does in the last minute only matters if there is
## a last minute.
##
## Built from the theme's own parts, so it reads as the same game as the menu it
## came from and the HUD it covers.
signal play_again
signal to_menu
var _result: Dictionary = {}
func show_result(result: Dictionary) -> void:
_result = result
for c in get_children():
c.queue_free()
_build()
visible = true
# The summary takes the mouse, which the match had captured.
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func _build() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
var wash := ColorRect.new()
wash.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.86)
wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(wash)
var centre := CenterContainer.new()
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(centre)
var card := UITheme.card()
card.custom_minimum_size = Vector2(880, 0)
centre.add_child(card)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 10)
card.add_child(col)
# ── Who won, and why ────────────────────────────────────────────────────
var winner := String(_result.get("name", "DRAW"))
var title := UITheme.title(winner if winner == "DRAW" else winner + " WINS",
68, -2.0)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
# A winning TEAM's name is written in that team's colour. The wordmark
# treatment is papaya by default, which on "MAGENTA TEAM WINS" says the
# opposite of what the words do.
var win_team := int(_result.get("team", 0))
if win_team > 0:
title.add_theme_color_override("font_color", GameMode.team_color(win_team))
col.add_child(title)
# The REASON, not just the result. "Time" and "frag limit" are different
# stories about the same scoreline and the player was in one of them.
var reason := "TIME"
if String(_result.get("reason", "")) == "score":
reason = "%s LIMIT REACHED" % GameMode.score_noun(
String(_result.get("mode", GameMode.DEATHMATCH)))
var sub := UITheme.caption("%s%s" % [
GameMode.display_name(String(_result.get("mode", GameMode.DEATHMATCH))),
reason], 22)
sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(sub)
var rule := UITheme.divider(0.42)
rule.custom_minimum_size = Vector2(0, 20)
col.add_child(rule)
# ── Team scores, if this was a team mode ────────────────────────────────
var mode := String(_result.get("mode", GameMode.DEATHMATCH))
var stats: Dictionary = _result.get("stats", {})
if GameMode.is_team_mode(mode):
var totals := GameMode.team_scores(mode, stats)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 46)
col.add_child(row)
for t in totals:
var box := VBoxContainer.new()
var nm := UITheme.heading(GameMode.team_name(t), 24)
nm.add_theme_color_override("font_color", GameMode.team_color(t))
nm.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(nm)
var sc := UITheme.heading(str(totals[t]), 54)
sc.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(sc)
row.add_child(box)
# ── The standings ───────────────────────────────────────────────────────
var grid := GridContainer.new()
grid.columns = 6
grid.add_theme_constant_override("h_separation", 34)
grid.add_theme_constant_override("v_separation", 6)
col.add_child(grid)
for h in ["#", "PLAYER", GameMode.score_noun(mode), "K", "D", "BEST RUN"]:
var lbl := UITheme.caption(h, 20)
grid.add_child(lbl)
var order: Array = _result.get("standings", [])
for i in order.size():
var pid = order[i]
var s: Dictionary = stats.get(pid, {})
var place := i + 1
# First place gets the volt. It is the only place in this UI outside the
# moment of input that volt appears, and winning is worth the exception.
var tint: Color = UITheme.VOLT if place == 1 else UITheme.PAPER
grid.add_child(_cell(str(place), tint, place == 1))
var who := _cell(String(s.get("username", "Player")), tint, place == 1)
if int(s.get("team", 0)) > 0:
who.add_theme_color_override("font_color",
GameMode.team_color(int(s.get("team", 0))))
grid.add_child(who)
grid.add_child(_cell(str(s.get("score", 0)), tint, place == 1))
grid.add_child(_cell(str(s.get("kills", 0)), tint, place == 1))
grid.add_child(_cell(str(s.get("deaths", 0)), tint, place == 1))
grid.add_child(_cell("x%d" % int(s.get("best_streak", 0)), tint, place == 1))
col.add_child(UITheme.divider(0.6))
# ── Out ─────────────────────────────────────────────────────────────────
var buttons := HBoxContainer.new()
buttons.alignment = BoxContainer.ALIGNMENT_CENTER
buttons.add_theme_constant_override("separation", 18)
col.add_child(buttons)
var again := UITheme.primary_button("PLAY AGAIN", 34)
again.pressed.connect(func(): play_again.emit())
buttons.add_child(again)
var menu := Button.new()
menu.text = "Main Menu"
menu.add_theme_font_size_override("font_size", 28)
menu.pressed.connect(func(): to_menu.emit())
buttons.add_child(menu)
# Only the host may restart a running server; everyone else waits for them.
var nm = get_tree().root.get_node_or_null("NetworkManager")
if nm and nm.multiplayer.has_multiplayer_peer() \
and not nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer \
and not nm.multiplayer.is_server():
again.disabled = true
again.text = "WAITING FOR HOST"
UITheme.wire_sounds(self)
again.grab_focus()
func _cell(text: String, tint: Color, bold: bool) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 30 if bold else 26)
l.add_theme_color_override("font_color", tint)
l.add_theme_color_override("font_outline_color", UITheme.INK)
l.add_theme_constant_override("outline_size", 6)
return l
+1
View File
@@ -0,0 +1 @@
uid://ygajwj3bek8k
+52
View File
@@ -17,10 +17,25 @@ func _ready() -> void:
_setup_viewmodel_viewport()
# Gun Game promotions arrive as a signal from the server.
var nm = get_node_or_null("/root/NetworkManager")
if nm and nm.has_signal("ladder_promoted"):
nm.ladder_promoted.connect(_on_ladder_promoted)
# Wait one frame for LoadoutManager to be fully ready if needed
await get_tree().process_frame
_build_loadout()
func _on_ladder_promoted(peer_id: int, _rung: int, weapon_id: String) -> void:
# Only the promoted player's own manager acts; every peer receives the
# signal because the killfeed uses it too.
if player == null or not player.is_multiplayer_authority():
return
if multiplayer.has_multiplayer_peer() and peer_id != multiplayer.get_unique_id():
return
equip_ladder_weapon(weapon_id)
func _setup_viewmodel_viewport() -> void:
if not camera: return
@@ -200,6 +215,16 @@ func _build_loadout() -> void:
player.synced_loadout_melee = ""
player.synced_loadout_ready = true
# Gun Game hands out the weapon, so the player's own loadout is ignored —
# the whole mode is "you get what your rung gives you". Checked here rather
# than in the mode so there is one place a loadout is built.
if _ladder_mode():
var nm = get_node_or_null("/root/NetworkManager")
var rung: int = int(nm.player_stats.get(multiplayer.get_unique_id(), {})
.get("rung", 0)) if nm else 0
equip_ladder_weapon(GameMode.ladder_weapon(rung))
return
_spawn_weapon(1, l["primary_1"])
_spawn_weapon(2, l["primary_2"])
_spawn_weapon(3, l["special"])
@@ -208,6 +233,33 @@ func _build_loadout() -> void:
_equip_slot(1)
## Whether the current match issues weapons instead of letting players pick.
func _ladder_mode() -> bool:
var nm = get_node_or_null("/root/NetworkManager")
return nm != null and nm.current_gamemode == GameMode.GUN_GAME
## Replace everything in hand with one issued weapon. Gun Game's promotion.
func equip_ladder_weapon(weapon_id: String) -> void:
if weapon_id == "":
return
for w in weapons.values():
if is_instance_valid(w):
w.queue_free()
weapons.clear()
_spawn_weapon(1, weapon_id)
if player:
# Remote peers build their view of this player from the synced loadout,
# so a promotion has to move that too or everyone else keeps seeing the
# gun from the previous rung in their hands.
player.synced_loadout_p1 = weapon_id
player.synced_loadout_p2 = ""
player.synced_loadout_sp = ""
player.synced_loadout_melee = ""
player.synced_loadout_ready = true
_equip_slot(1)
func _build_remote_loadout(p1: String, p2: String, sp: String, melee: String) -> void:
for w in weapons.values():
if is_instance_valid(w):