Files
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

266 lines
8.3 KiB
GDScript

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