diff --git a/debug/dance_check.gd.uid b/debug/dance_check.gd.uid new file mode 100644 index 0000000..7116b9a --- /dev/null +++ b/debug/dance_check.gd.uid @@ -0,0 +1 @@ +uid://tds0trjfr2as diff --git a/debug/emote_wheel_capture.gd.uid b/debug/emote_wheel_capture.gd.uid new file mode 100644 index 0000000..4176614 --- /dev/null +++ b/debug/emote_wheel_capture.gd.uid @@ -0,0 +1 @@ +uid://cxxajtpm3o8bj diff --git a/debug/game_mode_check.gd b/debug/game_mode_check.gd new file mode 100644 index 0000000..4199d76 --- /dev/null +++ b/debug/game_mode_check.gd @@ -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) diff --git a/debug/summary_capture.gd b/debug/summary_capture.gd new file mode 100644 index 0000000..90cc463 --- /dev/null +++ b/debug/summary_capture.gd @@ -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 -- +## +## 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} diff --git a/globals/game_mode.gd b/globals/game_mode.gd new file mode 100644 index 0000000..81308d0 --- /dev/null +++ b/globals/game_mode.gd @@ -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 diff --git a/globals/game_mode.gd.uid b/globals/game_mode.gd.uid new file mode 100644 index 0000000..70f6759 --- /dev/null +++ b/globals/game_mode.gd.uid @@ -0,0 +1 @@ +uid://b8k2ha37f47ji diff --git a/globals/network_manager.gd b/globals/network_manager.gd index 2dbadfa..c82020b 100644 --- a/globals/network_manager.gd +++ b/globals/network_manager.gd @@ -7,18 +7,34 @@ signal connection_succeeded signal match_state_updated signal stats_updated signal killfeed_event(victim: String, killer: String, weapon: String, v_color: String, k_color: String) +## The match is over. `result` carries the reason ("score" / "time"), the winner's +## name, and the final standings. The HUD raises the summary screen on this. +signal match_ended(result: Dictionary) +## A Gun Game player moved up a rung and needs their weapon swapped. +signal ladder_promoted(peer_id: int, rung: int, weapon_id: String) const DEFAULT_PORT = 31415 var connected_players: Array[int] = [] -# Dict of ID -> { "username": String, "kills": int, "deaths": int, "assists": int, "ping": int } +## ID -> { username, kills, deaths, assists, ping, color, score, team, streak, +## best_streak, rung } +## +## `score` is what the MODE counts and is what decides the match — in Deathmatch +## it tracks kills, in Gun Game it is the ladder rung. `kills` stays a plain +## kill count regardless, because the scoreboard shows both and they are not the +## same number in every mode. var player_stats: Dictionary = {} var match_active: bool = false var match_time_remaining: float = 0.0 -var current_gamemode: String = "Deathmatch" +## A GameMode id — see globals/game_mode.gd. Was a display string ("Deathmatch") +## compared by equality in three places; an id is what the mode table is keyed on +## and what survives being sent over the wire. +var current_gamemode: String = GameMode.DEATHMATCH var current_scene_path: String = "" +## Set when the match ends, so a late-arriving HUD can still show the summary. +var last_result: Dictionary = {} var _ping_timer: Timer @@ -62,31 +78,48 @@ func disconnect_game() -> void: match_active = false _ping_timer.stop() -func start_singleplayer_match(gamemode: String = "Deathmatch") -> void: +func start_singleplayer_match(gamemode: String = GameMode.DEATHMATCH) -> void: # Ensure peer is offline if not already connected if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer: multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() - + connected_players.clear() connected_players.append(1) - _init_player_stats(1, SettingsManager.username) - - match_active = true - match_time_remaining = 300.0 current_gamemode = gamemode + player_stats.clear() + _init_player_stats(1, SettingsManager.username) + + last_result = {} + match_active = true + # The mode's own clock, not a hardcoded five minutes. Gun Game is a fifteen + # minute mode and Deathmatch a ten minute one; both used to be five. + match_time_remaining = GameMode.time_limit(gamemode) rpc("sync_match_state", match_active, match_time_remaining, current_gamemode) func _init_player_stats(id: int, username: String) -> void: # Keep existing color if player re-joins, or generate new var p_color = Color(randf_range(0.2, 1.0), randf_range(0.2, 1.0), randf_range(0.2, 1.0)).to_html(false) - + var team := GameMode.assign_team(current_gamemode, player_stats) + if team > 0: + # In a team mode the player's colour IS their team's, because a killfeed + # where a teammate and an enemy are both "some random colour" is a + # killfeed that cannot be read at a glance. + p_color = GameMode.team_color(team).to_html(false) + player_stats[id] = { "username": username, "kills": 0, "deaths": 0, "assists": 0, "ping": 0, - "color": p_color + "color": p_color, + # What the MODE counts. See the note on player_stats. + "score": 0, + "team": team, + "streak": 0, + "best_streak": 0, + # Gun Game ladder position. + "rung": 0, } stats_updated.emit() @@ -169,20 +202,93 @@ func rpc_load_level(scene_path: String) -> void: print("Loading level: ", scene_path) current_scene_path = scene_path if multiplayer.is_server(): + # Reset the scores as well as the clock. Loading a level used to restart + # the timer and leave every kill from the previous match standing, so the + # second match on a server started with someone already at the frag limit + # — and now that reaching the limit ENDS the match, it would have ended + # on the first kill. + last_result = {} + for pid in player_stats: + for key in ["kills", "deaths", "assists", "score", "streak", + "best_streak", "rung"]: + player_stats[pid][key] = 0 match_active = true - match_time_remaining = 300.0 # 5 minutes + match_time_remaining = GameMode.time_limit(current_gamemode) + rpc("sync_player_stats", player_stats) rpc("sync_match_state", match_active, match_time_remaining, current_gamemode) get_tree().change_scene_to_file(scene_path) func _process(delta: float) -> void: - if match_active: - if match_time_remaining > 0: - match_time_remaining -= delta - if match_time_remaining <= 0: - match_time_remaining = 0 - match_active = false - # We emit this so HUDs can update their timers smoothly - match_state_updated.emit() + if not match_active: + return + if match_time_remaining > 0: + match_time_remaining -= delta + if match_time_remaining <= 0: + match_time_remaining = 0 + # Time is a win CONDITION, not an off switch. This used to set + # `match_active = false` and stop, so the clock reached zero, the + # timer froze at 00:00 and nothing else happened — no winner, no + # summary, no way back to the menu. The match did not end so much as + # stop mattering. + if _is_authority(): + _check_win() + # We emit this so HUDs can update their timers smoothly + match_state_updated.emit() + + +## Whether this peer decides the match. True for the server, and true in +## singleplayer, where there is no peer but somebody still has to call it. +func _is_authority() -> bool: + if not multiplayer.has_multiplayer_peer(): + return true + if multiplayer.multiplayer_peer is OfflineMultiplayerPeer: + return true + return multiplayer.is_server() + + +## Has anyone won? Called on the server after every kill and when the clock runs +## out. GameMode.check_win is a pure function of the stats, so the rule lives +## next to the mode that defines it and can be tested without a match running. +func _check_win() -> void: + if not match_active: + return + var result := GameMode.check_win(current_gamemode, player_stats, + match_time_remaining) + if result.is_empty(): + return + result["mode"] = current_gamemode + result["standings"] = GameMode.standings(player_stats) + result["stats"] = player_stats.duplicate(true) + if _is_authority() and multiplayer.has_multiplayer_peer() \ + and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer: + rpc("end_match", result) + else: + end_match(result) + + +@rpc("authority", "call_local", "reliable") +func end_match(result: Dictionary) -> void: + if not match_active: + return + match_active = false + last_result = result + match_state_updated.emit() + match_ended.emit(result) + + +## Wipe the scores and start the clock again on the same map and mode. The +## summary screen's "Play Again". +@rpc("authority", "call_local", "reliable") +func restart_match() -> void: + for pid in player_stats: + for key in ["kills", "deaths", "assists", "score", "streak", + "best_streak", "rung"]: + player_stats[pid][key] = 0 + last_result = {} + match_active = true + match_time_remaining = GameMode.time_limit(current_gamemode) + stats_updated.emit() + match_state_updated.emit() # --- PING SYSTEM --- func _on_ping_timer() -> void: @@ -208,19 +314,54 @@ func receive_ping_response(server_time: int) -> void: # --- GAMEPLAY EVENTS --- @rpc("any_peer", "call_local", "reliable") func register_kill(victim_id: int, killer_id: int, weapon_name: String, custom_victim_name: String = "", assist_ids: Array = []) -> void: - if multiplayer.is_server(): - if killer_id != 0 and killer_id != victim_id: - if player_stats.has(killer_id): - player_stats[killer_id]["kills"] += 1 - + if _is_authority(): + # A kill after the final whistle counts for nothing. Without this a + # rocket already in the air when the clock hit zero could change the + # result after the summary was on screen. + if not match_active: + return + + var clean_kill := killer_id != 0 and killer_id != victim_id + # A team kill is not a score. Friendly fire may be ON in a mode and the + # kill still must not advance the killer — otherwise the fastest way to + # win Team Deathmatch is to shoot your own team. + if clean_kill and GameMode.is_team_mode(current_gamemode) \ + and player_stats.has(killer_id) and player_stats.has(victim_id) \ + and player_stats[killer_id]["team"] == player_stats[victim_id]["team"]: + clean_kill = false + + if clean_kill and player_stats.has(killer_id): + var ks: Dictionary = player_stats[killer_id] + ks["kills"] += 1 + ks["streak"] += 1 + ks["best_streak"] = maxi(ks["best_streak"], ks["streak"]) + if current_gamemode == GameMode.GUN_GAME: + # A kill is a RUNG, not a point, and the rung IS the score — so + # reaching the top of the ladder is the same event as reaching + # the score limit, and only one win condition has to exist. + ks["rung"] += 1 + ks["score"] = ks["rung"] + var weapon := GameMode.ladder_weapon(ks["rung"]) + ladder_promoted.emit(killer_id, ks["rung"], weapon) + rpc("notify_promotion", killer_id, ks["rung"], weapon) + else: + ks["score"] += int(GameMode.get_mode(current_gamemode).get( + "kill_score", 1)) + if victim_id != -1: if player_stats.has(victim_id): player_stats[victim_id]["deaths"] += 1 - + player_stats[victim_id]["streak"] = 0 + var penalty := int(GameMode.get_mode(current_gamemode).get( + "death_score", 0)) + if penalty != 0: + player_stats[victim_id]["score"] = maxi( + player_stats[victim_id]["score"] - penalty, 0) + for aid in assist_ids: if player_stats.has(aid) and aid != killer_id and aid != victim_id: player_stats[aid]["assists"] += 1 - + var v_name = player_stats[victim_id]["username"] if player_stats.has(victim_id) else (custom_victim_name if custom_victim_name != "" else "Unknown") var k_name = player_stats[killer_id]["username"] if player_stats.has(killer_id) else "Unknown" @@ -238,9 +379,46 @@ func register_kill(victim_id: int, killer_id: int, weapon_name: String, custom_v if killer_id == 0 or killer_id == victim_id: k_name = "" # Suicide or world death - broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color) - sync_player_stats.rpc(player_stats) + # `.rpc()` on an offline peer does not call locally, so singleplayer got + # no killfeed and no stat sync at all. Both paths now go through the + # same two calls. + if multiplayer.has_multiplayer_peer() \ + and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer: + broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color) + sync_player_stats.rpc(player_stats) + else: + broadcast_killfeed(v_name, k_name, weapon_name, v_color, k_color) + sync_player_stats(player_stats) + + # The scores just changed, so this is exactly when a match can end. + _check_win() @rpc("authority", "call_local", "reliable") func broadcast_killfeed(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void: killfeed_event.emit(victim, killer, weapon, v_color, k_color) + + +## A Gun Game player moved up. Broadcast so every client's killfeed can say so +## and the promoted player's own client can swap their weapon. +@rpc("authority", "call_local", "reliable") +func notify_promotion(peer_id: int, rung: int, weapon_id: String) -> void: + ladder_promoted.emit(peer_id, rung, weapon_id) + + +## Whether `a` may damage `b`, per the mode. The only place friendly fire is +## decided, so a mode that turns it off turns it off everywhere. +func can_damage(attacker_id: int, victim_id: int) -> bool: + if attacker_id == victim_id: + return true + if GameMode.friendly_fire(current_gamemode): + return true + if not player_stats.has(attacker_id) or not player_stats.has(victim_id): + return true + var ta: int = int(player_stats[attacker_id].get("team", 0)) + var tb: int = int(player_stats[victim_id].get("team", 0)) + return ta == 0 or ta != tb + + +## Which team a peer is on, 0 for free-for-all. +func team_of(peer_id: int) -> int: + return int(player_stats.get(peer_id, {}).get("team", 0)) diff --git a/movement/player_movement_controller.gd b/movement/player_movement_controller.gd index 93cf781..34d7215 100644 --- a/movement/player_movement_controller.gd +++ b/movement/player_movement_controller.gd @@ -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. diff --git a/ui/match_hud.gd b/ui/match_hud.gd index dbb0eed..eaa224f 100644 --- a/ui/match_hud.gd +++ b/ui/match_hud.gd @@ -14,6 +14,35 @@ var _killfeed_vbox: VBoxContainer var _scoreboard_panel: PanelContainer var _scoreboard_grid: GridContainer +var _summary: MatchSummary + + +func _on_match_ended(result: Dictionary) -> void: + _scoreboard_panel.hide() + _summary.show_result(result) + + +func _on_play_again() -> void: + if not _nm: + return + # The host decides for everyone; a client's button is disabled. + if _nm.multiplayer.has_multiplayer_peer() \ + and not _nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer: + if _nm.multiplayer.is_server(): + _nm.restart_match.rpc() + else: + _nm.restart_match() + _summary.visible = false + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) + + +func _on_to_menu() -> void: + if _nm: + _nm.match_active = false + _nm.disconnect_game() + Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + get_tree().change_scene_to_file("res://ui/main_menu/main_menu.tscn") + func _ready() -> void: _nm = get_node_or_null("/root/NetworkManager") # Ensure the comic theme is active even when a level is loaded directly @@ -98,12 +127,27 @@ func _ready() -> void: _populate_scoreboard_headers() + # The end-of-match screen. Built once and hidden, rather than instantiated on + # the signal, so the moment the match ends nothing has to be loaded. + _summary = MatchSummary.new() + _summary.name = "MatchSummary" + _summary.visible = false + _summary.play_again.connect(_on_play_again) + _summary.to_menu.connect(_on_to_menu) + add_child(_summary) + # Signals if _nm: _nm.stats_updated.connect(_update_hud) _nm.killfeed_event.connect(_on_killfeed_event) _nm.match_state_updated.connect(_update_timer) + _nm.match_ended.connect(_on_match_ended) _update_hud() + # A HUD that loads AFTER the match ended — a late joiner, or a scene + # reload — still has to show the result, so it asks rather than only + # listening. `last_result` is empty during a live match. + if not _nm.last_result.is_empty(): + _on_match_ended(_nm.last_result) func _process(_delta: float) -> void: if Input.is_action_just_pressed("scoreboard"): @@ -125,8 +169,12 @@ func _update_timer() -> void: func _populate_scoreboard_headers() -> void: for child in _scoreboard_grid.get_children(): child.queue_free() - - var headers = ["Player", "Kills", "Deaths", "Assists", "Ping"] + + # SCORE is first and is the mode's own noun, because it is what decides the + # match. Kills and score are the same number in Deathmatch and different in + # Gun Game, which is exactly why both are shown. + var noun := GameMode.score_noun(_nm.current_gamemode) if _nm else "SCORE" + var headers = ["Player", noun, "Kills", "Deaths", "Assists", "Streak", "Ping"] for h in headers: var l = Label.new() l.text = h @@ -138,59 +186,59 @@ func _update_hud() -> void: if not _nm: return var my_id = multiplayer.get_unique_id() - - var leader_name = "None" - var max_kills = -1 - + var mode: String = _nm.current_gamemode + var noun := GameMode.score_noun(mode) var stats = _nm.player_stats - - # Sort players by kills for scoreboard - var players = stats.keys() - players.sort_custom(func(a, b): return stats[a].get("kills", 0) > stats[b].get("kills", 0)) - + + # Ranked by SCORE, which is what the mode counts — not by kills, which in + # Gun Game is the same number by coincidence and in a mode with objectives + # would not be. + var players := GameMode.standings(stats) _populate_scoreboard_headers() - + for pid in players: var p_data = stats[pid] - var p_kills = p_data.get("kills", 0) - - # Find leader - if p_kills > max_kills: - max_kills = p_kills - leader_name = p_data.get("username", "Player " + str(pid)) - - # Update local kills + var p_score = p_data.get("score", p_data.get("kills", 0)) + if pid == my_id: - _local_kills_label.text = str(p_kills) + " Kills" - - # Build Scoreboard Row + _local_kills_label.text = "%s %s" % [p_score, noun] + + var row_color := Color(p_data.get("color", "cccccc")) + if int(p_data.get("team", 0)) > 0: + row_color = GameMode.team_color(int(p_data.get("team", 0))) + var c_name = Label.new() c_name.text = p_data.get("username", "Player") c_name.add_theme_font_size_override("font_size", 24) - c_name.add_theme_color_override("font_color", Color(p_data.get("color", "cccccc"))) + c_name.add_theme_color_override("font_color", row_color) _scoreboard_grid.add_child(c_name) - - var c_k = Label.new() - c_k.text = str(p_kills) - c_k.add_theme_font_size_override("font_size", 24) - _scoreboard_grid.add_child(c_k) - - var c_d = Label.new() - c_d.text = str(p_data.get("deaths", 0)) - c_d.add_theme_font_size_override("font_size", 24) - _scoreboard_grid.add_child(c_d) - - var c_a = Label.new() - c_a.text = str(p_data.get("assists", 0)) - c_a.add_theme_font_size_override("font_size", 24) - _scoreboard_grid.add_child(c_a) - - var c_p = Label.new() - c_p.text = str(p_data.get("ping", 0)) + "ms" - c_p.add_theme_font_size_override("font_size", 24) - _scoreboard_grid.add_child(c_p) - - _leader_label.text = "Leader: %s (%s Kills)" % [leader_name, max_kills] + + for value in [str(p_score), str(p_data.get("kills", 0)), + str(p_data.get("deaths", 0)), str(p_data.get("assists", 0)), + "x%d" % int(p_data.get("best_streak", 0)), + "%dms" % int(p_data.get("ping", 0))]: + var cell = Label.new() + cell.text = value + cell.add_theme_font_size_override("font_size", 24) + _scoreboard_grid.add_child(cell) + + # The top line: who is leading, and how far there is to go. A score limit + # nobody can see is a win condition players cannot play toward. + var limit := GameMode.score_limit(mode) + if GameMode.is_team_mode(mode): + var totals := GameMode.team_scores(mode, stats) + var parts: PackedStringArray = [] + for t in totals: + parts.append("%s %d" % [GameMode.team_name(t), totals[t]]) + _leader_label.text = " / ".join(parts) + (" — to %d" % limit if limit > 0 else "") + elif players.is_empty(): + _leader_label.text = "Leader: None" + else: + var top = players[0] + _leader_label.text = "Leader: %s (%s%s)" % [ + stats[top].get("username", "Player " + str(top)), + stats[top].get("score", 0), + "/%d" % limit if limit > 0 else ""] func _on_killfeed_event(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void: var l = RichTextLabel.new() diff --git a/ui/match_summary.gd b/ui/match_summary.gd new file mode 100644 index 0000000..b662aff --- /dev/null +++ b/ui/match_summary.gd @@ -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 diff --git a/ui/match_summary.gd.uid b/ui/match_summary.gd.uid new file mode 100644 index 0000000..8df256f --- /dev/null +++ b/ui/match_summary.gd.uid @@ -0,0 +1 @@ +uid://ygajwj3bek8k diff --git a/weapons/weapon_manager.gd b/weapons/weapon_manager.gd index 52cc5eb..5012267 100644 --- a/weapons/weapon_manager.gd +++ b/weapons/weapon_manager.gd @@ -16,11 +16,26 @@ func _ready() -> void: set_process(true) _setup_viewmodel_viewport() - + + # Gun Game promotions arrive as a signal from the server. + var nm = get_node_or_null("/root/NetworkManager") + if nm and nm.has_signal("ladder_promoted"): + nm.ladder_promoted.connect(_on_ladder_promoted) + # Wait one frame for LoadoutManager to be fully ready if needed await get_tree().process_frame _build_loadout() + +func _on_ladder_promoted(peer_id: int, _rung: int, weapon_id: String) -> void: + # Only the promoted player's own manager acts; every peer receives the + # signal because the killfeed uses it too. + if player == null or not player.is_multiplayer_authority(): + return + if multiplayer.has_multiplayer_peer() and peer_id != multiplayer.get_unique_id(): + return + equip_ladder_weapon(weapon_id) + func _setup_viewmodel_viewport() -> void: if not camera: return @@ -200,12 +215,49 @@ func _build_loadout() -> void: player.synced_loadout_melee = "" player.synced_loadout_ready = true + # Gun Game hands out the weapon, so the player's own loadout is ignored — + # the whole mode is "you get what your rung gives you". Checked here rather + # than in the mode so there is one place a loadout is built. + if _ladder_mode(): + var nm = get_node_or_null("/root/NetworkManager") + var rung: int = int(nm.player_stats.get(multiplayer.get_unique_id(), {}) + .get("rung", 0)) if nm else 0 + equip_ladder_weapon(GameMode.ladder_weapon(rung)) + return + _spawn_weapon(1, l["primary_1"]) _spawn_weapon(2, l["primary_2"]) _spawn_weapon(3, l["special"]) if l.has("melee"): _spawn_weapon(4, l["melee"]) - + + _equip_slot(1) + + +## Whether the current match issues weapons instead of letting players pick. +func _ladder_mode() -> bool: + var nm = get_node_or_null("/root/NetworkManager") + return nm != null and nm.current_gamemode == GameMode.GUN_GAME + + +## Replace everything in hand with one issued weapon. Gun Game's promotion. +func equip_ladder_weapon(weapon_id: String) -> void: + if weapon_id == "": + return + for w in weapons.values(): + if is_instance_valid(w): + w.queue_free() + weapons.clear() + _spawn_weapon(1, weapon_id) + if player: + # Remote peers build their view of this player from the synced loadout, + # so a promotion has to move that too or everyone else keeps seeing the + # gun from the previous rung in their hands. + player.synced_loadout_p1 = weapon_id + player.synced_loadout_p2 = "" + player.synced_loadout_sp = "" + player.synced_loadout_melee = "" + player.synced_loadout_ready = true _equip_slot(1) func _build_remote_loadout(p1: String, p2: String, sp: String, melee: String) -> void: