extends CanvasLayer var _nm: Node # Top HUD var _time_label: Label var _local_kills_label: Label var _leader_label: Label # Killfeed var _killfeed_vbox: VBoxContainer # Scoreboard 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 # (tests, debug runs) without passing through the main menu. UITheme.apply_global(get_tree()) # Top HUD var top_hbox = HBoxContainer.new() top_hbox.set_anchors_preset(Control.PRESET_TOP_WIDE) top_hbox.alignment = BoxContainer.ALIGNMENT_CENTER top_hbox.add_theme_constant_override("separation", 50) var margin = MarginContainer.new() margin.add_theme_constant_override("margin_top", 20) margin.set_anchors_preset(Control.PRESET_TOP_WIDE) margin.add_child(top_hbox) add_child(margin) _time_label = Label.new() _time_label.add_theme_font_size_override("font_size", 48) _time_label.add_theme_color_override("font_outline_color", UITheme.INK) _time_label.add_theme_constant_override("outline_size", 8) top_hbox.add_child(_time_label) _local_kills_label = Label.new() _local_kills_label.text = "0 Kills" _local_kills_label.add_theme_font_size_override("font_size", 64) _local_kills_label.add_theme_color_override("font_color", UITheme.PAPAYA) _local_kills_label.add_theme_color_override("font_outline_color", UITheme.INK) _local_kills_label.add_theme_constant_override("outline_size", 12) top_hbox.add_child(_local_kills_label) _leader_label = Label.new() _leader_label.text = "Leader: None" _leader_label.add_theme_font_size_override("font_size", 32) _leader_label.add_theme_color_override("font_outline_color", UITheme.INK) _leader_label.add_theme_constant_override("outline_size", 8) top_hbox.add_child(_leader_label) # Killfeed var kf_margin = MarginContainer.new() kf_margin.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT) kf_margin.grow_horizontal = Control.GROW_DIRECTION_BEGIN kf_margin.add_theme_constant_override("margin_top", 20) kf_margin.add_theme_constant_override("margin_right", 20) add_child(kf_margin) _killfeed_vbox = VBoxContainer.new() _killfeed_vbox.alignment = BoxContainer.ALIGNMENT_END kf_margin.add_child(_killfeed_vbox) # Scoreboard Overlay var center_container = CenterContainer.new() center_container.set_anchors_preset(Control.PRESET_FULL_RECT) add_child(center_container) _scoreboard_panel = PanelContainer.new() _scoreboard_panel.custom_minimum_size = Vector2(800, 600) # Styled by the shared UITheme PanelContainer stylebox (ink-border card) _scoreboard_panel.hide() center_container.add_child(_scoreboard_panel) var sb_vbox = VBoxContainer.new() var sb_margin = MarginContainer.new() sb_margin.add_theme_constant_override("margin_left", 20) sb_margin.add_theme_constant_override("margin_top", 20) sb_margin.add_theme_constant_override("margin_right", 20) sb_margin.add_theme_constant_override("margin_bottom", 20) sb_margin.add_child(sb_vbox) _scoreboard_panel.add_child(sb_margin) var sb_title = Label.new() sb_title.text = "Scoreboard" sb_title.add_theme_font_size_override("font_size", 48) sb_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER sb_vbox.add_child(sb_title) _scoreboard_grid = GridContainer.new() _scoreboard_grid.columns = 5 _scoreboard_grid.add_theme_constant_override("h_separation", 50) _scoreboard_grid.add_theme_constant_override("v_separation", 10) sb_vbox.add_child(_scoreboard_grid) _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"): _scoreboard_panel.show() _update_hud() elif Input.is_action_just_released("scoreboard"): _scoreboard_panel.hide() if _nm and _nm.match_active: _update_timer() func _update_timer() -> void: if not _nm: return var t = int(_nm.match_time_remaining) var m = int(t / 60.0) var s = t % 60 _time_label.text = "%02d:%02d" % [m, s] func _populate_scoreboard_headers() -> void: for child in _scoreboard_grid.get_children(): child.queue_free() # 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 l.add_theme_font_size_override("font_size", 24) l.add_theme_color_override("font_color", UITheme.PAPER_DIM) _scoreboard_grid.add_child(l) func _update_hud() -> void: if not _nm: return var my_id = multiplayer.get_unique_id() var mode: String = _nm.current_gamemode var noun := GameMode.score_noun(mode) var stats = _nm.player_stats # 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_score = p_data.get("score", p_data.get("kills", 0)) if pid == my_id: _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", row_color) _scoreboard_grid.add_child(c_name) 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() l.bbcode_enabled = true l.fit_content = true l.autowrap_mode = TextServer.AUTOWRAP_OFF if killer == "": l.text = "[color=#%s]%s[/color] died." % [v_color, victim] else: l.text = "[color=#%s]%s[/color] [%s] [color=#%s]%s[/color]" % [k_color, killer, weapon, v_color, victim] l.add_theme_font_size_override("normal_font_size", 20) l.add_theme_color_override("font_outline_color", UITheme.INK) l.add_theme_constant_override("outline_size", 4) _killfeed_vbox.add_child(l) # Fade out and queue_free after 5 seconds var t = get_tree().create_tween() t.tween_interval(4.0) t.tween_property(l, "modulate:a", 0.0, 1.0) t.tween_callback(l.queue_free)