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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f1a4f7df52
commit
986179854d
@@ -0,0 +1 @@
|
||||
uid://tds0trjfr2as
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxxajtpm3o8bj
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8k2ha37f47ji
|
||||
+206
-28
@@ -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,31 +78,48 @@ 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)
|
||||
_init_player_stats(1, SettingsManager.username)
|
||||
|
||||
match_active = true
|
||||
match_time_remaining = 300.0
|
||||
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
|
||||
"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,20 +202,93 @@ 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 match_time_remaining > 0:
|
||||
match_time_remaining -= delta
|
||||
if match_time_remaining <= 0:
|
||||
match_time_remaining = 0
|
||||
match_active = false
|
||||
# We emit this so HUDs can update their timers smoothly
|
||||
match_state_updated.emit()
|
||||
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:
|
||||
@@ -208,19 +314,54 @@ 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:
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color)
|
||||
sync_player_stats.rpc(player_stats)
|
||||
# `.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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
+94
-46
@@ -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"):
|
||||
@@ -125,8 +169,12 @@ func _update_timer() -> void:
|
||||
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)
|
||||
|
||||
# Find leader
|
||||
if p_kills > max_kills:
|
||||
max_kills = p_kills
|
||||
leader_name = p_data.get("username", "Player " + str(pid))
|
||||
|
||||
# Update local kills
|
||||
var p_score = p_data.get("score", p_data.get("kills", 0))
|
||||
|
||||
if pid == my_id:
|
||||
_local_kills_label.text = str(p_kills) + " Kills"
|
||||
|
||||
# Build Scoreboard Row
|
||||
_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)))
|
||||
|
||||
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)
|
||||
|
||||
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]
|
||||
|
||||
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)
|
||||
|
||||
# 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()
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ygajwj3bek8k
|
||||
@@ -16,11 +16,26 @@ func _ready() -> void:
|
||||
set_process(true)
|
||||
|
||||
_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,12 +215,49 @@ 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"])
|
||||
if l.has("melee"):
|
||||
_spawn_weapon(4, l["melee"])
|
||||
|
||||
|
||||
_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:
|
||||
|
||||
Reference in New Issue
Block a user