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