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
|
||||||
+206
-28
@@ -7,18 +7,34 @@ signal connection_succeeded
|
|||||||
signal match_state_updated
|
signal match_state_updated
|
||||||
signal stats_updated
|
signal stats_updated
|
||||||
signal killfeed_event(victim: String, killer: String, weapon: String, v_color: String, k_color: String)
|
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
|
const DEFAULT_PORT = 31415
|
||||||
|
|
||||||
var connected_players: Array[int] = []
|
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 player_stats: Dictionary = {}
|
||||||
|
|
||||||
var match_active: bool = false
|
var match_active: bool = false
|
||||||
var match_time_remaining: float = 0.0
|
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 = ""
|
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
|
var _ping_timer: Timer
|
||||||
|
|
||||||
@@ -62,31 +78,48 @@ func disconnect_game() -> void:
|
|||||||
match_active = false
|
match_active = false
|
||||||
_ping_timer.stop()
|
_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
|
# Ensure peer is offline if not already connected
|
||||||
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
||||||
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
||||||
|
|
||||||
connected_players.clear()
|
connected_players.clear()
|
||||||
connected_players.append(1)
|
connected_players.append(1)
|
||||||
_init_player_stats(1, SettingsManager.username)
|
|
||||||
|
|
||||||
match_active = true
|
|
||||||
match_time_remaining = 300.0
|
|
||||||
current_gamemode = gamemode
|
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)
|
rpc("sync_match_state", match_active, match_time_remaining, current_gamemode)
|
||||||
|
|
||||||
func _init_player_stats(id: int, username: String) -> void:
|
func _init_player_stats(id: int, username: String) -> void:
|
||||||
# Keep existing color if player re-joins, or generate new
|
# 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 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] = {
|
player_stats[id] = {
|
||||||
"username": username,
|
"username": username,
|
||||||
"kills": 0,
|
"kills": 0,
|
||||||
"deaths": 0,
|
"deaths": 0,
|
||||||
"assists": 0,
|
"assists": 0,
|
||||||
"ping": 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()
|
stats_updated.emit()
|
||||||
|
|
||||||
@@ -169,20 +202,93 @@ func rpc_load_level(scene_path: String) -> void:
|
|||||||
print("Loading level: ", scene_path)
|
print("Loading level: ", scene_path)
|
||||||
current_scene_path = scene_path
|
current_scene_path = scene_path
|
||||||
if multiplayer.is_server():
|
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_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)
|
rpc("sync_match_state", match_active, match_time_remaining, current_gamemode)
|
||||||
get_tree().change_scene_to_file(scene_path)
|
get_tree().change_scene_to_file(scene_path)
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if match_active:
|
if not match_active:
|
||||||
if match_time_remaining > 0:
|
return
|
||||||
match_time_remaining -= delta
|
if match_time_remaining > 0:
|
||||||
if match_time_remaining <= 0:
|
match_time_remaining -= delta
|
||||||
match_time_remaining = 0
|
if match_time_remaining <= 0:
|
||||||
match_active = false
|
match_time_remaining = 0
|
||||||
# We emit this so HUDs can update their timers smoothly
|
# Time is a win CONDITION, not an off switch. This used to set
|
||||||
match_state_updated.emit()
|
# `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 ---
|
# --- PING SYSTEM ---
|
||||||
func _on_ping_timer() -> void:
|
func _on_ping_timer() -> void:
|
||||||
@@ -208,19 +314,54 @@ func receive_ping_response(server_time: int) -> void:
|
|||||||
# --- GAMEPLAY EVENTS ---
|
# --- GAMEPLAY EVENTS ---
|
||||||
@rpc("any_peer", "call_local", "reliable")
|
@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:
|
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 _is_authority():
|
||||||
if killer_id != 0 and killer_id != victim_id:
|
# A kill after the final whistle counts for nothing. Without this a
|
||||||
if player_stats.has(killer_id):
|
# rocket already in the air when the clock hit zero could change the
|
||||||
player_stats[killer_id]["kills"] += 1
|
# 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 victim_id != -1:
|
||||||
if player_stats.has(victim_id):
|
if player_stats.has(victim_id):
|
||||||
player_stats[victim_id]["deaths"] += 1
|
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:
|
for aid in assist_ids:
|
||||||
if player_stats.has(aid) and aid != killer_id and aid != victim_id:
|
if player_stats.has(aid) and aid != killer_id and aid != victim_id:
|
||||||
player_stats[aid]["assists"] += 1
|
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 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"
|
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:
|
if killer_id == 0 or killer_id == victim_id:
|
||||||
k_name = "" # Suicide or world death
|
k_name = "" # Suicide or world death
|
||||||
|
|
||||||
broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color)
|
# `.rpc()` on an offline peer does not call locally, so singleplayer got
|
||||||
sync_player_stats.rpc(player_stats)
|
# 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")
|
@rpc("authority", "call_local", "reliable")
|
||||||
func broadcast_killfeed(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void:
|
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)
|
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:
|
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
|
||||||
if not multiplayer.is_server(): return
|
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.
|
# 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,
|
# Knockback is applied inside rpc_take_damage on the victim's own peer,
|
||||||
# since that peer simulates this body.
|
# since that peer simulates this body.
|
||||||
|
|||||||
+94
-46
@@ -14,6 +14,35 @@ var _killfeed_vbox: VBoxContainer
|
|||||||
var _scoreboard_panel: PanelContainer
|
var _scoreboard_panel: PanelContainer
|
||||||
var _scoreboard_grid: GridContainer
|
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:
|
func _ready() -> void:
|
||||||
_nm = get_node_or_null("/root/NetworkManager")
|
_nm = get_node_or_null("/root/NetworkManager")
|
||||||
# Ensure the comic theme is active even when a level is loaded directly
|
# Ensure the comic theme is active even when a level is loaded directly
|
||||||
@@ -98,12 +127,27 @@ func _ready() -> void:
|
|||||||
|
|
||||||
_populate_scoreboard_headers()
|
_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
|
# Signals
|
||||||
if _nm:
|
if _nm:
|
||||||
_nm.stats_updated.connect(_update_hud)
|
_nm.stats_updated.connect(_update_hud)
|
||||||
_nm.killfeed_event.connect(_on_killfeed_event)
|
_nm.killfeed_event.connect(_on_killfeed_event)
|
||||||
_nm.match_state_updated.connect(_update_timer)
|
_nm.match_state_updated.connect(_update_timer)
|
||||||
|
_nm.match_ended.connect(_on_match_ended)
|
||||||
_update_hud()
|
_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:
|
func _process(_delta: float) -> void:
|
||||||
if Input.is_action_just_pressed("scoreboard"):
|
if Input.is_action_just_pressed("scoreboard"):
|
||||||
@@ -125,8 +169,12 @@ func _update_timer() -> void:
|
|||||||
func _populate_scoreboard_headers() -> void:
|
func _populate_scoreboard_headers() -> void:
|
||||||
for child in _scoreboard_grid.get_children():
|
for child in _scoreboard_grid.get_children():
|
||||||
child.queue_free()
|
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:
|
for h in headers:
|
||||||
var l = Label.new()
|
var l = Label.new()
|
||||||
l.text = h
|
l.text = h
|
||||||
@@ -138,59 +186,59 @@ func _update_hud() -> void:
|
|||||||
if not _nm: return
|
if not _nm: return
|
||||||
|
|
||||||
var my_id = multiplayer.get_unique_id()
|
var my_id = multiplayer.get_unique_id()
|
||||||
|
var mode: String = _nm.current_gamemode
|
||||||
var leader_name = "None"
|
var noun := GameMode.score_noun(mode)
|
||||||
var max_kills = -1
|
|
||||||
|
|
||||||
var stats = _nm.player_stats
|
var stats = _nm.player_stats
|
||||||
|
|
||||||
# Sort players by kills for scoreboard
|
# Ranked by SCORE, which is what the mode counts — not by kills, which in
|
||||||
var players = stats.keys()
|
# Gun Game is the same number by coincidence and in a mode with objectives
|
||||||
players.sort_custom(func(a, b): return stats[a].get("kills", 0) > stats[b].get("kills", 0))
|
# would not be.
|
||||||
|
var players := GameMode.standings(stats)
|
||||||
_populate_scoreboard_headers()
|
_populate_scoreboard_headers()
|
||||||
|
|
||||||
for pid in players:
|
for pid in players:
|
||||||
var p_data = stats[pid]
|
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:
|
if pid == my_id:
|
||||||
_local_kills_label.text = str(p_kills) + " Kills"
|
_local_kills_label.text = "%s %s" % [p_score, noun]
|
||||||
|
|
||||||
# Build Scoreboard Row
|
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()
|
var c_name = Label.new()
|
||||||
c_name.text = p_data.get("username", "Player")
|
c_name.text = p_data.get("username", "Player")
|
||||||
c_name.add_theme_font_size_override("font_size", 24)
|
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)
|
_scoreboard_grid.add_child(c_name)
|
||||||
|
|
||||||
var c_k = Label.new()
|
for value in [str(p_score), str(p_data.get("kills", 0)),
|
||||||
c_k.text = str(p_kills)
|
str(p_data.get("deaths", 0)), str(p_data.get("assists", 0)),
|
||||||
c_k.add_theme_font_size_override("font_size", 24)
|
"x%d" % int(p_data.get("best_streak", 0)),
|
||||||
_scoreboard_grid.add_child(c_k)
|
"%dms" % int(p_data.get("ping", 0))]:
|
||||||
|
var cell = Label.new()
|
||||||
var c_d = Label.new()
|
cell.text = value
|
||||||
c_d.text = str(p_data.get("deaths", 0))
|
cell.add_theme_font_size_override("font_size", 24)
|
||||||
c_d.add_theme_font_size_override("font_size", 24)
|
_scoreboard_grid.add_child(cell)
|
||||||
_scoreboard_grid.add_child(c_d)
|
|
||||||
|
# The top line: who is leading, and how far there is to go. A score limit
|
||||||
var c_a = Label.new()
|
# nobody can see is a win condition players cannot play toward.
|
||||||
c_a.text = str(p_data.get("assists", 0))
|
var limit := GameMode.score_limit(mode)
|
||||||
c_a.add_theme_font_size_override("font_size", 24)
|
if GameMode.is_team_mode(mode):
|
||||||
_scoreboard_grid.add_child(c_a)
|
var totals := GameMode.team_scores(mode, stats)
|
||||||
|
var parts: PackedStringArray = []
|
||||||
var c_p = Label.new()
|
for t in totals:
|
||||||
c_p.text = str(p_data.get("ping", 0)) + "ms"
|
parts.append("%s %d" % [GameMode.team_name(t), totals[t]])
|
||||||
c_p.add_theme_font_size_override("font_size", 24)
|
_leader_label.text = " / ".join(parts) + (" — to %d" % limit if limit > 0 else "")
|
||||||
_scoreboard_grid.add_child(c_p)
|
elif players.is_empty():
|
||||||
|
_leader_label.text = "Leader: None"
|
||||||
_leader_label.text = "Leader: %s (%s Kills)" % [leader_name, max_kills]
|
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:
|
func _on_killfeed_event(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void:
|
||||||
var l = RichTextLabel.new()
|
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)
|
set_process(true)
|
||||||
|
|
||||||
_setup_viewmodel_viewport()
|
_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
|
# Wait one frame for LoadoutManager to be fully ready if needed
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
_build_loadout()
|
_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:
|
func _setup_viewmodel_viewport() -> void:
|
||||||
if not camera: return
|
if not camera: return
|
||||||
|
|
||||||
@@ -200,12 +215,49 @@ func _build_loadout() -> void:
|
|||||||
player.synced_loadout_melee = ""
|
player.synced_loadout_melee = ""
|
||||||
player.synced_loadout_ready = true
|
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(1, l["primary_1"])
|
||||||
_spawn_weapon(2, l["primary_2"])
|
_spawn_weapon(2, l["primary_2"])
|
||||||
_spawn_weapon(3, l["special"])
|
_spawn_weapon(3, l["special"])
|
||||||
if l.has("melee"):
|
if l.has("melee"):
|
||||||
_spawn_weapon(4, l["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)
|
_equip_slot(1)
|
||||||
|
|
||||||
func _build_remote_loadout(p1: String, p2: String, sp: String, melee: String) -> void:
|
func _build_remote_loadout(p1: String, p2: String, sp: String, melee: String) -> void:
|
||||||
|
|||||||
Reference in New Issue
Block a user