extends Control ## Leaderboard panel — reads from Nakama native leaderboard (global_high_score). ## Left: sortable leaderboard list. ## Right: 3D SubViewport character preview of the selected/top-ranked player. signal closed # ------------------------------------------------------------------------- # UI References # ------------------------------------------------------------------------- @onready var back_btn := %BackBtn as Button @onready var refresh_btn := %RefreshBtn as Button @onready var sync_btn := %SyncBtn as Button @onready var sort_score_btn := %SortScoreBtn as Button @onready var sort_win_rate_btn := %SortWinRateBtn as Button @onready var sort_games_btn := %SortGamesBtn as Button @onready var leaderboard_list := %LeaderboardList as VBoxContainer @onready var status_label := %StatusLabel as Label # 3D Preview @onready var character_root := %CharacterRoot as Node3D @onready var selected_name_label := %SelectedNameLabel as Label @onready var selected_rank_label := %SelectedRankLabel as Label # ------------------------------------------------------------------------- # State # ------------------------------------------------------------------------- var leaderboard_data: Array = [] var current_sort_key: String = "high_score" var current_selected_index: int = 0 var _anim_player: AnimationPlayer # Maps game character name -> GLB node name in the SubViewport const CHAR_NODE_MAP: Dictionary = { "Copper": "Oldpop", "Dabro": "Masbro", "Gatot": "Gatot", "Pip": "Bob" } # Avatar index -> character name (same order as UserProfileManager.AVATARS) const AVATAR_TO_CHAR: Array[String] = ["Pip", "Gatot", "Dabro", "Copper"] func _ready() -> void: back_btn.pressed.connect(_on_close_pressed) refresh_btn.pressed.connect(_fetch_leaderboard_data) sync_btn.pressed.connect(_on_sync_pressed) sort_score_btn.pressed.connect(func(): _sort_by("high_score")) sort_win_rate_btn.pressed.connect(func(): _sort_by("win_rate")) sort_games_btn.pressed.connect(func(): _sort_by("games_played")) _update_tab_visuals() _setup_3d_preview() # ------------------------------------------------------------------------- # Show / Close # ------------------------------------------------------------------------- func show_panel() -> void: show() status_label.text = "Syncing scores..." # Bulk-sync all users' storage stats to native leaderboard (server-side operation) if NakamaManager.session: var sync_result = await NakamaManager.client.rpc_async(NakamaManager.session, "sync_leaderboard", "{}") if sync_result.is_exception(): push_error("[Leaderboard] sync_leaderboard RPC failed: ", sync_result.get_exception().message) else: print("[Leaderboard] Server sync finished: ", sync_result.payload) _fetch_leaderboard_data() func _on_close_pressed() -> void: hide() emit_signal("closed") func _on_sync_pressed() -> void: """Push the current player's stored stats up to the native Nakama leaderboard.""" if not NakamaManager.session or AuthManager.is_guest: status_label.text = "Must be logged in to sync" return status_label.text = "Syncing your score..." await UserProfileManager.submit_to_leaderboard() status_label.text = "Synced! Refreshing..." await get_tree().create_timer(0.5).timeout _fetch_leaderboard_data() # ------------------------------------------------------------------------- # Data # ------------------------------------------------------------------------- func _fetch_leaderboard_data() -> void: if not NakamaManager.session: status_label.text = "Not connected to Nakama" return status_label.text = "Fetching Leaderboard..." for child in leaderboard_list.get_children(): child.queue_free() # Try native Nakama leaderboard first (fastest, ranked already) var native_data = await _fetch_native_leaderboard() if native_data.size() > 0: leaderboard_data = native_data _calculate_win_rates() status_label.text = "" _sort_by(current_sort_key) if leaderboard_data.size() > 0: _show_entry_preview(0) else: # Fallback: try the server RPC (reads same native leaderboard) await _fetch_via_rpc() func _fetch_native_leaderboard() -> Array: """Use the Nakama client API to list native leaderboard records directly.""" var result = await NakamaManager.client.list_leaderboard_records_async( NakamaManager.session, "global_high_score", [], # no specific owner filter null, # expiry = null (no filter) 100 # limit ) if result.is_exception(): push_warning("[Leaderboard] Native API failed: ", result.get_exception().message) return [] var data: Array = [] for record in result.records: var meta: Dictionary = {} if record.metadata and not record.metadata.is_empty(): var parsed = JSON.parse_string(record.metadata) if parsed is Dictionary: meta = parsed if record.owner_id == NakamaManager.session.user_id: print("[Leaderboard] Local player meta: ", meta) data.append({ "user_id": record.owner_id, "display_name": record.username if (record.username and not record.username.is_empty()) else "Unknown", "avatar_url": meta.get("avatar_url", ""), "loadout_character": meta.get("loadout_character", "Copper"), "high_score": int(record.score), "games_played": int(meta.get("games_played", 0)), "games_won": int(meta.get("games_won", 0)), "rank": int(record.rank) }) return data func _fetch_via_rpc() -> void: """Fallback: call server RPC which reads the same native leaderboard.""" var result = await NakamaManager.client.rpc_async(NakamaManager.session, "get_leaderboard_stats", "{}") if result.is_exception(): status_label.text = "Failed to load leaderboard" push_error("[Leaderboard] RPC failed: ", result.get_exception().message) return var json := JSON.new() if json.parse(result.payload) == OK: var data = json.get_data() if data.has("leaderboard") and data.leaderboard.size() > 0: leaderboard_data = data.leaderboard _calculate_win_rates() status_label.text = "" _sort_by(current_sort_key) if leaderboard_data.size() > 0: _show_entry_preview(0) else: # No records exist yet — show a helpful hint status_label.text = "No scores recorded yet.\nPlay a match to appear here!" else: status_label.text = "Error parsing server data" func _calculate_win_rates() -> void: for entry in leaderboard_data: var played = entry.get("games_played", 0) var won = entry.get("games_won", 0) entry["win_rate"] = float(won) / float(played) * 100.0 if played > 0 else 0.0 # ------------------------------------------------------------------------- # Sorting / Display # ------------------------------------------------------------------------- func _sort_by(key: String) -> void: current_sort_key = key current_selected_index = 0 _update_tab_visuals() leaderboard_data.sort_custom(func(a, b): return a.get(key, 0) > b.get(key, 0)) _populate_list() func _update_tab_visuals() -> void: var color_active = Color(0.647, 0.996, 0.224, 1) var color_inactive = Color(0.69, 0.529, 0.357, 1) sort_score_btn.add_theme_color_override("font_color", color_active if current_sort_key == "high_score" else color_inactive) sort_win_rate_btn.add_theme_color_override("font_color", color_active if current_sort_key == "win_rate" else color_inactive) sort_games_btn.add_theme_color_override("font_color", color_active if current_sort_key == "games_played" else color_inactive) # Make the background of unselected tabs visibly darker via modulate so they contrast against the active yellow background sort_score_btn.self_modulate = Color(1, 1, 1) if current_sort_key == "high_score" else Color(0.5, 0.5, 0.5) sort_win_rate_btn.self_modulate = Color(1, 1, 1) if current_sort_key == "win_rate" else Color(0.5, 0.5, 0.5) sort_games_btn.self_modulate = Color(1, 1, 1) if current_sort_key == "games_played" else Color(0.5, 0.5, 0.5) func _populate_list() -> void: for child in leaderboard_list.get_children(): child.queue_free() if leaderboard_data.size() == 0: status_label.text = "No players found.\nPlay a match to appear here!" return for i in range(leaderboard_data.size()): var entry = leaderboard_data[i] _create_leaderboard_item(i + 1, entry, i) func _create_leaderboard_item(rank: int, entry: Dictionary, index: int) -> void: var item = PanelContainer.new() var style = StyleBoxFlat.new() if index == current_selected_index: # Highlight color for the currently selected player row style.bg_color = Color(0.25, 0.35, 0.20, 1.0) elif rank <= 3: style.bg_color = Color(0.2, 0.2, 0.15, 1.0) else: style.bg_color = Color(0.15, 0.15, 0.15, 1.0) style.set_corner_radius_all(4) style.content_margin_left = 10 style.content_margin_right = 10 style.content_margin_top = 8 style.content_margin_bottom = 8 item.add_theme_stylebox_override("panel", style) var hbox = HBoxContainer.new() hbox.add_theme_constant_override("separation", 16) item.add_child(hbox) # Rank var rank_label = Label.new() rank_label.text = "#" + str(rank) rank_label.custom_minimum_size = Vector2(40, 0) match rank: 1: rank_label.add_theme_color_override("font_color", Color.GOLD) 2: rank_label.add_theme_color_override("font_color", Color.SILVER) 3: rank_label.add_theme_color_override("font_color", Color.DARK_ORANGE) _: rank_label.add_theme_color_override("font_color", Color.LIGHT_GRAY) hbox.add_child(rank_label) # Avatar var avatar_rect = TextureRect.new() avatar_rect.custom_minimum_size = Vector2(32, 32) avatar_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE var avatar_url = entry.get("avatar_url", "") if avatar_url.is_empty() or not ResourceLoader.exists(avatar_url): if not avatar_url.is_empty(): print("[Leaderboard] Avatar URL not found or invalid: ", avatar_url) avatar_url = UserProfileManager.AVATARS[0] avatar_rect.texture = load(avatar_url) hbox.add_child(avatar_rect) # Name var name_label = Label.new() name_label.text = entry.get("display_name", "Unknown") name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL name_label.add_theme_color_override("font_color", Color.WHITE) hbox.add_child(name_label) # Value var value_label = Label.new() var color = Color(0.647, 0.996, 0.224, 1) match current_sort_key: "high_score": value_label.text = str(entry.get("high_score", 0)) "win_rate": value_label.text = "%.1f%%" % entry.get("win_rate", 0.0) "games_played": value_label.text = str(entry.get("games_played", 0)) value_label.add_theme_color_override("font_color", color) value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT value_label.custom_minimum_size = Vector2(80, 0) hbox.add_child(value_label) leaderboard_list.add_child(item) # Make row clickable to update 3D preview item.gui_input.connect(func(event: InputEvent): if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: current_selected_index = index _populate_list() # Re-draw list to apply the new active highlight colors visually _show_entry_preview(index) ) item.mouse_filter = Control.MOUSE_FILTER_STOP # ------------------------------------------------------------------------- # 3D Preview # ------------------------------------------------------------------------- func _setup_3d_preview() -> void: if not character_root: return _anim_player = character_root.get_node_or_null("AnimationPlayer") func _show_entry_preview(index: int) -> void: if index >= leaderboard_data.size(): return var entry = leaderboard_data[index] # Determine character from metadata or avatar_url index var char_name: String = entry.get("loadout_character", "") if char_name.is_empty(): var avatar_url: String = entry.get("avatar_url", "") char_name = "Copper" for i in range(UserProfileManager.AVATARS.size()): if UserProfileManager.AVATARS[i] == avatar_url: char_name = AVATAR_TO_CHAR[i] if i < AVATAR_TO_CHAR.size() else "Copper" break _update_3d_preview(char_name) var display_name: String = entry.get("display_name", "Unknown") var rank := index + 1 selected_name_label.text = display_name selected_rank_label.text = "#%d" % rank func _update_3d_preview(character_name: String) -> void: if not character_root: return var node_name: String = CHAR_NODE_MAP.get(character_name, "Masbro") for child in character_root.get_children(): if child is Node3D: child.visible = (child.name == node_name) if _anim_player: var new_root := character_root.get_node_or_null(node_name) if new_root: _anim_player.root_node = new_root.get_path() if _anim_player.has_animation("animation-pack/idle"): _anim_player.play("animation-pack/idle") elif _anim_player.get_animation_list().size() > 0: _anim_player.play(_anim_player.get_animation_list()[0])