Feat/fidelity pass 2 #23
@@ -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
|
||||
+199
-21
@@ -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,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,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
|
||||
|
||||
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.
|
||||
|
||||
+87
-39
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user