extends CanvasLayer class_name DatawhaleHonorRankingPanel const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const TEX_PANEL_FRAME = preload("res://assets/ui/datawhale_honor/honor_panel_wood_frame.png") const TEX_WINDOW_SHELL = preload("res://assets/ui/datawhale_honor/honor_window_shell.png") const TEX_PARCHMENT_PANEL = preload("res://assets/ui/datawhale_honor/honor_parchment_panel.png") const TEX_HEADER_PLANK = preload("res://assets/ui/datawhale_honor/honor_header_plank.png") const TEX_TITLE_DATAWHALE = preload("res://assets/ui/datawhale_honor/honor_title_datawhale.png") const TEX_TAB_NORMAL = preload("res://assets/ui/datawhale_honor/honor_tab_normal.png") const TEX_TAB_ACTIVE = preload("res://assets/ui/datawhale_honor/honor_tab_active.png") const TEX_CLAIM_BUTTON = preload("res://assets/ui/datawhale_honor/honor_claim_button.png") const TEX_CLOSE_BUTTON = preload("res://assets/ui/datawhale_honor/honor_close_button.png") const TEX_WHALE_CREST = preload("res://assets/ui/datawhale_honor/honor_whale_crest.png") const TEX_MEDAL_GOLD = preload("res://assets/ui/datawhale_honor/honor_medal_gold.png") const TEX_MEDAL_SILVER = preload("res://assets/ui/datawhale_honor/honor_medal_silver.png") const TEX_MEDAL_BRONZE = preload("res://assets/ui/datawhale_honor/honor_medal_bronze.png") const TEX_ICON_SCORE = preload("res://assets/ui/datawhale_honor/honor_icon_score_globe.png") const TEX_ICON_CONTRIBUTION = preload("res://assets/ui/datawhale_honor/honor_icon_contribution_whale.png") const TEX_ICON_REPO = preload("res://assets/ui/datawhale_honor/honor_icon_repo_chest.png") const TEX_ICON_STAR = preload("res://assets/ui/datawhale_honor/honor_icon_star.png") const TEX_ICON_REWARD = preload("res://assets/ui/datawhale_honor/honor_icon_reward_gem.png") const TEX_ICON_GIFT = preload("res://assets/ui/datawhale_honor/honor_icon_gift.png") const TEX_RANK_BADGE_LOWER = preload("res://assets/ui/datawhale_honor/honor_rank_badge_lower.png") const TEX_TAB_LABEL_WEEKLY = preload("res://assets/ui/datawhale_honor/honor_tab_label_weekly_commits.png") const TEX_TAB_LABEL_NIGHT = preload("res://assets/ui/datawhale_honor/honor_tab_label_night_owl.png") const TEX_TAB_LABEL_POPULARITY = preload("res://assets/ui/datawhale_honor/honor_tab_label_popularity.png") const TEX_TAB_LABEL_PRODUCTIVE = preload("res://assets/ui/datawhale_honor/honor_tab_label_productive.png") const TEX_TAB_LABEL_SOCIAL = preload("res://assets/ui/datawhale_honor/honor_tab_label_social.png") const TEX_TAB_LABEL_RISING = preload("res://assets/ui/datawhale_honor/honor_tab_label_rising.png") const TEX_TAB_LABEL_COMPREHENSIVE = preload("res://assets/ui/datawhale_honor/honor_tab_label_comprehensive.png") const FONT_UI = preload("res://assets/fonts/msyh-web.ttf") const TEX_AVATAR_FALLBACK_1 = preload("res://assets/ui/auth/generated/auth_character_preview_front.png") const TEX_AVATAR_FALLBACK_2 = preload("res://assets/ui/auth/generated/auth_character_preview_left.png") const TEX_AVATAR_FALLBACK_3 = preload("res://assets/ui/auth/generated/auth_character_preview_right.png") const API_PATH: String = "/rankings/datawhale-honor" const PANEL_SIZE: Vector2 = Vector2(1500, 850) const REQUEST_LIMIT: int = 9 const LOWER_RANK_LIMIT: int = 6 const LOWER_RANK_SLOT_HEIGHT: float = 45.0 const LOWER_RANK_AVATAR_SIZE: float = 30.0 const LOWER_RANK_AVATAR_SLOT_SIZE: float = 34.0 const LOWER_RANK_SLOT_MARGIN_LEFT: int = 18 const LOWER_RANK_SLOT_MARGIN_TOP: int = 12 const LOWER_RANK_SLOT_MARGIN_RIGHT: int = 22 const RANK_LIST_MIN_HEIGHT: float = 282.0 const PODIUM_PANEL_HEIGHT: float = 154.0 const PODIUM_CARD_TOP: float = -2.0 const PODIUM_CARD_HEIGHT: float = 143.0 const PODIUM_SLOT_BOUNDS: Dictionary = { 2: Vector2(14, 428), 1: Vector2(441, 892), 3: Vector2(899, 1308), } const TAB_LABEL_SIZE: Vector2 = Vector2(140, 36) const TAB_BUTTON_TOP: float = 0.0 const TAB_BUTTON_BOTTOM: float = 60.0 const TAB_SLOT_BOUNDS: Dictionary = { "weekly_commits": Vector2(88, 262), "night_owl": Vector2(266, 475), "popularity": Vector2(484, 658), "productive": Vector2(668, 868), "social": Vector2(878, 1058), "rising": Vector2(1068, 1224), "comprehensive": Vector2(1234, 1410), } const TEXT_COLOR: Color = Color(0.235, 0.125, 0.052) const MUTED_COLOR: Color = Color(0.455, 0.312, 0.170) const ACCENT_COLOR: Color = Color(0.075, 0.435, 0.740) const DEEP_BLUE: Color = Color(0.030, 0.285, 0.560) const GOLD: Color = Color(1.0, 0.710, 0.125) const SILVER: Color = Color(0.695, 0.802, 0.940) const BRONZE: Color = Color(0.885, 0.520, 0.285) const CREAM: Color = Color(0.992, 0.900, 0.705) const PARCHMENT_TINT: Color = Color(0.972, 0.842, 0.560) const WOOD_DARK: Color = Color(0.320, 0.160, 0.070) var _rootControl: Control var _panel: Control var _tabsRow: Control var _podiumPanel: PanelContainer var _rankScroll: ScrollContainer var _podiumRow: Control var _rankList: VBoxContainer var _footerLabel: Label var _statusLabel: Label var _request: HTTPRequest var _avatarRequests: Array[HTTPRequest] = [] var _chatUi: Control var _chatUiPrevMouseFilter: Control.MouseFilter = Control.MOUSE_FILTER_STOP var _chatUiMouseDisabled: bool = false var _categories: Array = [] var _activeCategory: String = "weekly_commits" var _isLoading: bool = false var _debugShowRankSlots: bool = false func _ready() -> void: get_tree().paused = true _disableChatUiMouseInput() _buildUi() get_tree().root.size_changed.connect(_positionPanel) _fetchRanking(_activeCategory) func _exit_tree() -> void: if get_tree().root.size_changed.is_connected(_positionPanel): get_tree().root.size_changed.disconnect(_positionPanel) _clearAvatarRequests() _restoreChatUiMouseInput() func _input(event: InputEvent) -> void: if event is InputEventKey: var keyEvent := event as InputEventKey if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE: _onClosePressed() get_viewport().set_input_as_handled() func _buildUi() -> void: _rootControl = Control.new() _rootControl.name = "RankingPanelRoot" _rootControl.set_anchors_preset(Control.PRESET_FULL_RECT) add_child(_rootControl) var dimmer := ColorRect.new() dimmer.name = "Dimmer" dimmer.set_anchors_preset(Control.PRESET_FULL_RECT) dimmer.color = Color(0.026, 0.055, 0.105, 0.58) dimmer.mouse_filter = Control.MOUSE_FILTER_STOP _rootControl.add_child(dimmer) _panel = Control.new() _panel.name = "HonorRankingPanel" _panel.custom_minimum_size = PANEL_SIZE _panel.mouse_filter = Control.MOUSE_FILTER_STOP _rootControl.add_child(_panel) _positionPanel() var frame := PanelContainer.new() frame.name = "MainFrame" frame.set_anchors_preset(Control.PRESET_FULL_RECT) frame.add_theme_stylebox_override("panel", _createPanelStyle(Color(0, 0, 0, 0), 0, Color(0, 0, 0, 0), 0, false)) frame.mouse_filter = Control.MOUSE_FILTER_IGNORE _panel.add_child(frame) var shell := TextureRect.new() shell.name = "WindowShell" shell.set_anchors_preset(Control.PRESET_FULL_RECT) shell.texture = TEX_WINDOW_SHELL shell.expand_mode = TextureRect.EXPAND_IGNORE_SIZE shell.stretch_mode = TextureRect.STRETCH_SCALE shell.mouse_filter = Control.MOUSE_FILTER_IGNORE _panel.add_child(shell) _panel.add_child(_buildTitleOverlay()) var margin := MarginContainer.new() margin.set_anchors_preset(Control.PRESET_FULL_RECT) margin.add_theme_constant_override("margin_left", 86) margin.add_theme_constant_override("margin_top", 92) margin.add_theme_constant_override("margin_right", 86) margin.add_theme_constant_override("margin_bottom", 76) _panel.add_child(margin) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 4) content.set_anchors_preset(Control.PRESET_FULL_RECT) content.size_flags_vertical = Control.SIZE_EXPAND_FILL margin.add_child(content) content.add_child(_buildHeader()) content.add_child(_buildBoardBody()) _panel.add_child(_buildTabsOverlay()) _panel.add_child(_buildRuleOverlay()) _panel.add_child(_buildCloseButton()) _request = HTTPRequest.new() _request.timeout = 18.0 _request.request_completed.connect(_onRequestCompleted) add_child(_request) _setLoadingState() func _buildBoardBody() -> Control: var body := PanelContainer.new() body.name = "BoardBody" body.size_flags_vertical = Control.SIZE_EXPAND_FILL body.add_theme_stylebox_override("panel", _createPanelStyle(Color(0, 0, 0, 0), 0, Color(0, 0, 0, 0), 0, false)) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 0) margin.add_theme_constant_override("margin_top", 0) margin.add_theme_constant_override("margin_right", 0) margin.add_theme_constant_override("margin_bottom", 0) body.add_child(margin) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 4) content.size_flags_vertical = Control.SIZE_EXPAND_FILL margin.add_child(content) var tabsSpacer := Control.new() tabsSpacer.name = "TabsSpacer" tabsSpacer.custom_minimum_size = Vector2(0, 58) content.add_child(tabsSpacer) _podiumPanel = _buildPodiumPanel() content.add_child(_podiumPanel) _rankScroll = ScrollContainer.new() _rankScroll.name = "RankScroll" _rankScroll.custom_minimum_size = Vector2(0, RANK_LIST_MIN_HEIGHT) _rankScroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL _rankScroll.size_flags_vertical = Control.SIZE_EXPAND_FILL _rankScroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _rankScroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED content.add_child(_rankScroll) var rankSlotMargin := MarginContainer.new() rankSlotMargin.size_flags_horizontal = Control.SIZE_EXPAND_FILL rankSlotMargin.add_theme_constant_override("margin_left", LOWER_RANK_SLOT_MARGIN_LEFT) rankSlotMargin.add_theme_constant_override("margin_top", LOWER_RANK_SLOT_MARGIN_TOP) rankSlotMargin.add_theme_constant_override("margin_right", LOWER_RANK_SLOT_MARGIN_RIGHT) _rankScroll.add_child(rankSlotMargin) _rankList = VBoxContainer.new() _rankList.size_flags_horizontal = Control.SIZE_EXPAND_FILL _rankList.add_theme_constant_override("separation", 7) rankSlotMargin.add_child(_rankList) content.add_child(_buildFooter()) return body func _positionPanel() -> void: if _rootControl == null: return var viewportSize := _rootControl.get_viewport_rect().size var scaleFactor: float = minf(1.0, minf((viewportSize.x - 64.0) / PANEL_SIZE.x, (viewportSize.y - 64.0) / PANEL_SIZE.y)) _panel.size = PANEL_SIZE _panel.scale = Vector2(scaleFactor, scaleFactor) _panel.position = (viewportSize - PANEL_SIZE * scaleFactor) * 0.5 func _fetchRanking(categoryId: String) -> void: if _request == null: return if _isLoading: _request.cancel_request() _activeCategory = categoryId _isLoading = true _setLoadingState() var endpoint := "%s%s?category=%s&limit=%d&refresh=true" % [ NetworkConfig.get_api_base_url(), API_PATH, _urlEncode(_activeCategory), REQUEST_LIMIT, ] var err := _request.request(endpoint, PackedStringArray(), HTTPClient.METHOD_GET, "") if err != OK: _isLoading = false _setErrorState("荣誉榜请求失败:%s" % error_string(err)) func _onRequestCompleted(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void: _isLoading = false if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300: _setErrorState("荣誉榜加载失败,请稍后再试") return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if not parsed is Dictionary: _setErrorState("荣誉榜数据解析失败") return var payload := parsed as Dictionary if not bool(payload.get("success", true)): _setErrorState(str(payload.get("message", "荣誉榜加载失败"))) return var dataVariant: Variant = payload.get("data", {}) if not dataVariant is Dictionary: _setErrorState("荣誉榜数据为空") return _renderRanking(dataVariant as Dictionary) func _renderRanking(data: Dictionary) -> void: _activeCategory = str(data.get("activeCategory", _activeCategory)) var categoriesVariant: Variant = data.get("categories", []) _categories = categoriesVariant as Array if categoriesVariant is Array else [] _renderTabs() var topRankers: Array = data.get("topRankers", []) as Array if data.get("topRankers", []) is Array else [] var rankers: Array = data.get("rankers", []) as Array if data.get("rankers", []) is Array else [] _renderPodium(topRankers) _renderRankList(rankers) if is_instance_valid(_statusLabel): _statusLabel.text = _buildStatusText(data) func _renderTabs() -> void: _clearChildren(_tabsRow) for category in _categories: if not category is Dictionary: continue var categoryData := category as Dictionary var id := str(categoryData.get("id", "")) var tabIndex := _tabsRow.get_child_count() var selected := id == _activeCategory var tabBounds := _tabSlotBounds(id, tabIndex) var button := Button.new() button.text = str(categoryData.get("label", id)) button.set_anchors_preset(Control.PRESET_TOP_LEFT) button.offset_left = tabBounds.x button.offset_top = TAB_BUTTON_TOP button.offset_right = tabBounds.y button.offset_bottom = TAB_BUTTON_BOTTOM button.focus_mode = Control.FOCUS_NONE button.alignment = HORIZONTAL_ALIGNMENT_CENTER button.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS button.disabled = selected or _isLoading _applyTransparentButtonText(button) button.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) button.add_theme_stylebox_override("hover", StyleBoxEmpty.new()) button.add_theme_stylebox_override("pressed", _createTabSelectedStyle()) button.add_theme_stylebox_override("disabled", _createTabSelectedStyle() if selected else StyleBoxEmpty.new()) button.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) button.add_child(_buildTabContent(id)) button.pressed.connect(func() -> void: _fetchRanking(id) ) _tabsRow.add_child(button) func _renderPodium(rows: Array) -> void: _clearChildren(_podiumRow) _clearAvatarRequests() if rows.is_empty(): _podiumRow.add_child(_buildEmptyLabel("正在等待荣誉榜数据...")) return var orderedRows := rows.duplicate() orderedRows.sort_custom(func(a: Variant, b: Variant) -> bool: var rankA := int((a as Dictionary).get("rank", 999)) if a is Dictionary else 999 var rankB := int((b as Dictionary).get("rank", 999)) if b is Dictionary else 999 var visualOrder := {2: 0, 1: 1, 3: 2} return int(visualOrder.get(rankA, rankA + 10)) < int(visualOrder.get(rankB, rankB + 10)) ) for row in orderedRows: if row is Dictionary: var rank := int((row as Dictionary).get("rank", 0)) if rank < 1 or rank > 3: continue var bounds := _podiumSlotBounds(rank) var card := _buildPodiumCard(row as Dictionary, Vector2(bounds.y - bounds.x, PODIUM_CARD_HEIGHT), 1 if rank == 1 else 0) card.set_anchors_preset(Control.PRESET_TOP_LEFT) card.offset_left = bounds.x card.offset_top = PODIUM_CARD_TOP card.offset_right = bounds.y card.offset_bottom = PODIUM_CARD_TOP + PODIUM_CARD_HEIGHT _podiumRow.add_child(card) func _renderRankList(rows: Array) -> void: _clearChildren(_rankList) if rows.is_empty(): _rankList.add_child(_buildEmptyLabel("暂无更多上榜成员")) return var shownCount := 0 for row in rows: if shownCount >= LOWER_RANK_LIMIT: break if row is Dictionary: _rankList.add_child(_buildRankRow(row as Dictionary)) shownCount += 1 func _setLoadingState() -> void: _clearChildren(_rankList) _renderTabs() _clearChildren(_podiumRow) _podiumRow.add_child(_buildEmptyLabel("正在同步 Datawhale 荣誉榜...")) _rankList.add_child(_buildEmptyLabel("数据来自 WhaleTown 后端同步接口")) if is_instance_valid(_footerLabel): _footerLabel.text = "我的排名: 正在同步..." if is_instance_valid(_statusLabel): _statusLabel.text = "正在从后端获取真实排行榜数据..." func _setErrorState(message: String) -> void: _clearChildren(_rankList) _clearChildren(_podiumRow) _podiumRow.add_child(_buildEmptyLabel("荣誉榜暂时不可用")) var retry := Button.new() retry.text = "重试同步" retry.custom_minimum_size = Vector2(180, 48) retry.focus_mode = Control.FOCUS_NONE retry.add_theme_font_size_override("font_size", 20) retry.add_theme_color_override("font_color", Color.WHITE) retry.add_theme_stylebox_override("normal", _createPanelStyle(ACCENT_COLOR, 22, Color(0.500, 0.820, 1.0, 1.0), 2, false)) retry.add_theme_stylebox_override("hover", _createPanelStyle(Color(0.180, 0.650, 1.0, 1.0), 22, Color(0.650, 0.900, 1.0, 1.0), 2, false)) retry.pressed.connect(func() -> void: _fetchRanking(_activeCategory) ) _rankList.add_child(_buildEmptyLabel(message)) _rankList.add_child(retry) if is_instance_valid(_footerLabel): _footerLabel.text = "我的排名: 暂无" if is_instance_valid(_statusLabel): _statusLabel.text = message func _buildHeader() -> Control: var header := Control.new() header.custom_minimum_size = Vector2(0, 66) return header func _buildTitleOverlay() -> Control: var overlay := Control.new() overlay.name = "TitleOverlay" overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE overlay.set_anchors_preset(Control.PRESET_TOP_WIDE) overlay.offset_left = 392 overlay.offset_top = 62 overlay.offset_right = -356 overlay.offset_bottom = 110 var title := TextureRect.new() title.texture = TEX_TITLE_DATAWHALE title.set_anchors_preset(Control.PRESET_FULL_RECT) title.expand_mode = TextureRect.EXPAND_IGNORE_SIZE title.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED title.mouse_filter = Control.MOUSE_FILTER_IGNORE overlay.add_child(title) var titleTextProbe := Label.new() titleTextProbe.name = "TitleTextProbe" titleTextProbe.text = "Datawhale Honor Ranking" titleTextProbe.visible = false overlay.add_child(titleTextProbe) return overlay func _buildTabsOverlay() -> Control: _tabsRow = Control.new() _tabsRow.name = "TabsOverlay" _tabsRow.mouse_filter = Control.MOUSE_FILTER_PASS _tabsRow.set_anchors_preset(Control.PRESET_TOP_WIDE) _tabsRow.offset_left = 0 _tabsRow.offset_top = 138 _tabsRow.offset_right = 0 _tabsRow.offset_bottom = 198 _tabsRow.clip_contents = false return _tabsRow func _buildTabContent(categoryId: String) -> Control: var center := Control.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) center.mouse_filter = Control.MOUSE_FILTER_IGNORE var labelImage := TextureRect.new() labelImage.texture = _tabLabelTexture(categoryId) labelImage.set_anchors_preset(Control.PRESET_CENTER) var visualOffset := _tabLabelOffset(categoryId) labelImage.offset_left = -TAB_LABEL_SIZE.x * 0.5 + visualOffset.x labelImage.offset_top = -TAB_LABEL_SIZE.y * 0.5 + visualOffset.y labelImage.offset_right = TAB_LABEL_SIZE.x * 0.5 + visualOffset.x labelImage.offset_bottom = TAB_LABEL_SIZE.y * 0.5 + visualOffset.y labelImage.expand_mode = TextureRect.EXPAND_IGNORE_SIZE labelImage.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED labelImage.mouse_filter = Control.MOUSE_FILTER_IGNORE center.add_child(labelImage) return center func _buildWhaleBadge() -> Control: var badge := TextureRect.new() badge.custom_minimum_size = Vector2(82, 72) badge.texture = TEX_WHALE_CREST badge.expand_mode = TextureRect.EXPAND_IGNORE_SIZE badge.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED return badge func _buildSeasonPill() -> Control: var pill := PanelContainer.new() pill.custom_minimum_size = Vector2(150, 42) pill.add_theme_stylebox_override("panel", _createPanelStyle(Color(0.105, 0.385, 0.610, 0.96), 16, Color(0.840, 0.610, 0.250, 0.95), 2, false)) var label := Label.new() label.text = "◷ 周更新" label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.add_theme_font_size_override("font_size", 17) label.add_theme_color_override("font_color", Color.WHITE) pill.add_child(label) return pill func _buildCloseButton() -> Button: var button := Button.new() button.name = "CloseButton" button.text = "×" button.tooltip_text = "关闭荣誉榜" button.custom_minimum_size = Vector2(62, 62) button.set_anchors_preset(Control.PRESET_TOP_RIGHT) button.offset_left = -174 button.offset_top = 46 button.offset_right = -118 button.offset_bottom = 102 button.focus_mode = Control.FOCUS_NONE button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND button.add_theme_font_size_override("font_size", 42) button.add_theme_color_override("font_color", Color(1, 1, 1, 0)) button.add_theme_color_override("font_hover_color", Color(1, 1, 1, 0)) button.add_theme_color_override("font_pressed_color", Color(1, 1, 1, 0)) button.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) button.add_theme_stylebox_override("hover", StyleBoxEmpty.new()) button.add_theme_stylebox_override("pressed", StyleBoxEmpty.new()) button.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) button.pressed.connect(_onClosePressed) return button func _buildPodiumPanel() -> PanelContainer: var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(0, PODIUM_PANEL_HEIGHT) panel.add_theme_stylebox_override("panel", _createPanelStyle(Color(0, 0, 0, 0), 0, Color(0, 0, 0, 0), 0, false)) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 0) margin.add_theme_constant_override("margin_top", 0) margin.add_theme_constant_override("margin_right", 0) margin.add_theme_constant_override("margin_bottom", 0) panel.add_child(margin) _podiumRow = Control.new() _podiumRow.custom_minimum_size = Vector2(0, PODIUM_PANEL_HEIGHT) _podiumRow.size_flags_horizontal = Control.SIZE_EXPAND_FILL _podiumRow.clip_contents = false margin.add_child(_podiumRow) return panel func _buildPodiumCard(data: Dictionary, minSize: Vector2, lift: int) -> Control: var rank := int(data.get("rank", 0)) var card := Control.new() card.custom_minimum_size = minSize card.clip_contents = false var width := minSize.x var contentInset := Vector2(8, 6) var mainContentShift := 12.0 var rightInfoShift := 36.0 var rightInfoLeft := 156 + contentInset.x + rightInfoShift + mainContentShift var avatarLeft := 64 + contentInset.x + mainContentShift var avatarTop := 17 + contentInset.y var medalSize := Vector2(56, 68) if rank == 1 else Vector2(50, 62) var medal := _createIcon(_rankMedalTexture(rank), medalSize) medal.set_anchors_preset(Control.PRESET_TOP_LEFT) medal.offset_left = 8 + contentInset.x medal.offset_top = (18 if rank == 1 else 21) + contentInset.y medal.offset_right = medal.offset_left + medalSize.x medal.offset_bottom = medal.offset_top + medalSize.y card.add_child(medal) var avatarSize := 64.0 var avatar := _createPodiumAvatar(data, avatarSize, 25 if rank == 1 else 23) avatar.set_anchors_preset(Control.PRESET_TOP_LEFT) avatar.offset_left = avatarLeft avatar.offset_top = avatarTop avatar.offset_right = avatar.offset_left + avatarSize avatar.offset_bottom = avatar.offset_top + avatarSize card.add_child(avatar) var info := VBoxContainer.new() info.set_anchors_preset(Control.PRESET_TOP_LEFT) info.offset_left = avatarLeft - 18 info.offset_top = avatarTop + avatarSize + 4 info.offset_right = avatarLeft + avatarSize + 18 info.offset_bottom = info.offset_top + 36 info.add_theme_constant_override("separation", 1) card.add_child(info) var tagsRow := HBoxContainer.new() tagsRow.set_anchors_preset(Control.PRESET_TOP_LEFT) tagsRow.offset_left = rightInfoLeft tagsRow.offset_top = 17 + contentInset.y tagsRow.offset_right = width - 68 + contentInset.x + rightInfoShift tagsRow.offset_bottom = 37 + contentInset.y tagsRow.add_theme_constant_override("separation", 5) card.add_child(tagsRow) for tagText in _podiumTags(data): tagsRow.add_child(_createPodiumTag(tagText)) var nameLabel := Label.new() nameLabel.text = str(data.get("name", "未知用户")) nameLabel.custom_minimum_size = Vector2(0, 18) nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _applyTextStyle(nameLabel, 13, TEXT_COLOR) info.add_child(nameLabel) var locationLabel := Label.new() locationLabel.text = _podiumMetaLine(data) locationLabel.custom_minimum_size = Vector2(0, 15) locationLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS locationLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER locationLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _applyTextStyle(locationLabel, 10, MUTED_COLOR) info.add_child(locationLabel) var scoreBlock := VBoxContainer.new() scoreBlock.set_anchors_preset(Control.PRESET_TOP_LEFT) scoreBlock.offset_left = rightInfoLeft scoreBlock.offset_top = 39 + contentInset.y scoreBlock.offset_right = width - 110 + contentInset.x + rightInfoShift scoreBlock.offset_bottom = 83 + contentInset.y scoreBlock.add_theme_constant_override("separation", 0) card.add_child(scoreBlock) var scoreCaption := Label.new() scoreCaption.text = _scoreCaption(_activeCategory) scoreCaption.custom_minimum_size = Vector2(0, 18) scoreCaption.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT scoreCaption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _applyTextStyle(scoreCaption, 11, MUTED_COLOR) scoreBlock.add_child(scoreCaption) var scoreLabel := Label.new() scoreLabel.text = _formatNumber(int(data.get("score", 0))) scoreLabel.custom_minimum_size = Vector2(0, 26) scoreLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT scoreLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _applyTextStyle(scoreLabel, 19, DEEP_BLUE) scoreBlock.add_child(scoreLabel) var scoreSubLabel := Label.new() scoreSubLabel.text = str(data.get("scoreLabel", "")).strip_edges() scoreSubLabel.custom_minimum_size = Vector2(0, 18) scoreSubLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT scoreSubLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _applyTextStyle(scoreSubLabel, 11, MUTED_COLOR) scoreBlock.add_child(scoreSubLabel) var statRow := HBoxContainer.new() statRow.set_anchors_preset(Control.PRESET_TOP_WIDE) statRow.offset_left = rightInfoLeft statRow.offset_top = 96 + contentInset.y statRow.offset_right = -128 + contentInset.x + rightInfoShift statRow.offset_bottom = 120 + contentInset.y statRow.alignment = BoxContainer.ALIGNMENT_BEGIN statRow.add_theme_constant_override("separation", 4) card.add_child(statRow) statRow.add_child(_createPodiumMiniStat(TEX_ICON_CONTRIBUTION, int(data.get("contrib", 0)))) statRow.add_child(_createPodiumMiniStat(TEX_ICON_REPO, int(data.get("answers", 0)))) statRow.add_child(_createPodiumMiniStat(TEX_ICON_STAR, int(data.get("likes", 0)))) return card func _buildRankRow(data: Dictionary) -> Control: var rank := int(data.get("rank", 0)) var rowPanel := PanelContainer.new() rowPanel.name = "LowerRankSlot%d" % rank rowPanel.custom_minimum_size = Vector2(0, LOWER_RANK_SLOT_HEIGHT) rowPanel.size_flags_horizontal = Control.SIZE_EXPAND_FILL if _debugShowRankSlots: rowPanel.add_theme_stylebox_override("panel", _createPanelStyle(Color(0.050, 0.440, 1.0, 0.10), 8, Color(0.050, 0.440, 1.0, 0.95), 2, false)) else: rowPanel.add_theme_stylebox_override("panel", StyleBoxEmpty.new()) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 34) margin.add_theme_constant_override("margin_right", 34) rowPanel.add_child(margin) var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 10) margin.add_child(row) row.add_child(_createRankBadge(rank)) row.add_child(_createRankAvatarSlot(data)) row.add_child(_createFixedLabel(str(data.get("name", "未知用户")), 232, 18, TEXT_COLOR, HORIZONTAL_ALIGNMENT_LEFT)) row.add_child(_createScoreMetricText(_scoreCaption(_activeCategory), _formatNumber(int(data.get("score", 0))), 172)) row.add_child(_createMetricPill(TEX_ICON_CONTRIBUTION, _contribLabel(_activeCategory), int(data.get("contrib", 0)))) row.add_child(_createMetricPill(TEX_ICON_REPO, _repoLabel(_activeCategory), int(data.get("answers", 0)))) row.add_child(_createMetricPill(TEX_ICON_STAR, _starLabel(_activeCategory), int(data.get("likes", 0)))) return rowPanel func _buildFooter() -> Control: var footerPanel := PanelContainer.new() footerPanel.custom_minimum_size = Vector2(0, 62) footerPanel.add_theme_stylebox_override("panel", _createPanelStyle(Color(0, 0, 0, 0), 0, Color(0, 0, 0, 0), 0, false)) return footerPanel func _buildRuleOverlay() -> Control: var overlay := Control.new() overlay.name = "RuleOverlay" overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE overlay.set_anchors_preset(Control.PRESET_TOP_WIDE) overlay.offset_left = 96 overlay.offset_top = 750 overlay.offset_right = -96 overlay.offset_bottom = 814 _statusLabel = Label.new() _statusLabel.text = _rankingRuleText(_activeCategory) _statusLabel.set_anchors_preset(Control.PRESET_FULL_RECT) _statusLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _statusLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _statusLabel.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _statusLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _statusLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE _applyTextStyle(_statusLabel, 16, MUTED_COLOR) overlay.add_child(_statusLabel) return overlay func _buildEmptyLabel(text: String) -> Label: var label := Label.new() label.text = text label.custom_minimum_size = Vector2(0, 52) label.size_flags_horizontal = Control.SIZE_EXPAND_FILL label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.add_theme_font_size_override("font_size", 20) label.add_theme_color_override("font_color", MUTED_COLOR) return label func _buildStatusText(data: Dictionary) -> String: return _rankingRuleText(str(data.get("activeCategory", _activeCategory))) func _createRankBadge(rank: int) -> Control: var badge := Control.new() badge.custom_minimum_size = Vector2(44, 42) var texture := TextureRect.new() texture.set_anchors_preset(Control.PRESET_FULL_RECT) texture.texture = TEX_RANK_BADGE_LOWER texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED texture.mouse_filter = Control.MOUSE_FILTER_IGNORE badge.add_child(texture) var label := Label.new() label.text = "%d" % rank label.set_anchors_preset(Control.PRESET_FULL_RECT) label.offset_top = 7 label.offset_bottom = 0 label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _applyTextStyle(label, 17, DEEP_BLUE) label.add_theme_color_override("font_shadow_color", Color(1.0, 0.940, 0.700, 0.96)) label.add_theme_constant_override("shadow_offset_y", 1) label.add_theme_constant_override("shadow_outline_size", 2) badge.add_child(label) return badge func _createPodiumAvatar(data: Dictionary, size: float, fontSize: int) -> PanelContainer: var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(size, size) panel.clip_contents = true panel.add_theme_stylebox_override("panel", _createAvatarFrameStyle(int(round(size * 0.5)))) var textureRect := TextureRect.new() textureRect.name = "AvatarTexture" textureRect.set_anchors_preset(Control.PRESET_FULL_RECT) textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE textureRect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED textureRect.mouse_filter = Control.MOUSE_FILTER_IGNORE textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR panel.add_child(textureRect) var label := Label.new() label.name = "AvatarFallbackLabel" label.text = str(data.get("avatarText", "鲸")) label.set_anchors_preset(Control.PRESET_FULL_RECT) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _applyTextStyle(label, fontSize, TEXT_COLOR) panel.add_child(label) var texture := _avatarPrimaryTextureFromData(data) if texture != null: textureRect.texture = texture label.visible = false else: var avatarUrl := _avatarUrlFromData(data) if not avatarUrl.is_empty(): _requestAvatarTexture(avatarUrl, textureRect, label, null) else: label.visible = true return panel func _createRankAvatarSlot(data: Dictionary) -> Control: var slot := CenterContainer.new() slot.custom_minimum_size = Vector2(LOWER_RANK_AVATAR_SLOT_SIZE, LOWER_RANK_AVATAR_SLOT_SIZE) slot.size_flags_vertical = Control.SIZE_SHRINK_CENTER var avatar := _createPodiumAvatar(data, LOWER_RANK_AVATAR_SIZE, 14) avatar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER avatar.size_flags_vertical = Control.SIZE_SHRINK_CENTER slot.add_child(avatar) return slot func _createAvatarLabel(text: String, size: float, fontSize: int) -> Label: var label := Label.new() label.text = text label.custom_minimum_size = Vector2(size, size) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _applyTextStyle(label, fontSize, TEXT_COLOR) return label func _createMetricPill(iconTexture: Texture2D, labelText: String, value: int) -> Control: var pill := PanelContainer.new() pill.custom_minimum_size = Vector2(132, 34) pill.add_theme_stylebox_override("panel", StyleBoxEmpty.new()) var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 6) pill.add_child(row) row.add_child(_createIcon(iconTexture, Vector2(20, 20))) row.add_child(_createFixedLabel("%s %s" % [labelText, _compactNumber(value)], 90, 16, Color(0.270, 0.150, 0.055), HORIZONTAL_ALIGNMENT_CENTER)) return pill func _createPodiumTag(text: String) -> Control: var tag := PanelContainer.new() var width := clampf(float(text.length()) * 7.0 + 16.0, 42.0, 76.0) tag.custom_minimum_size = Vector2(width, 19) tag.add_theme_stylebox_override("panel", _createPanelStyle(Color(0.965, 0.900, 0.700, 0.92), 7, Color(0.760, 0.510, 0.200, 0.52), 1, false)) var label := Label.new() label.text = text label.clip_text = true label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _applyTextStyle(label, 9, Color(0.365, 0.205, 0.060)) tag.add_child(label) return tag func _createPodiumMiniStat(iconTexture: Texture2D, value: int) -> Control: var row := HBoxContainer.new() row.custom_minimum_size = Vector2(54, 20) row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 3) row.add_child(_createIcon(iconTexture, Vector2(15, 15))) var label := Label.new() label.text = _compactNumber(value) label.custom_minimum_size = Vector2(34, 18) label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _applyTextStyle(label, 11, Color(0.335, 0.230, 0.120)) row.add_child(label) return row func _createFixedLabel(text: String, width: float, fontSize: int, color: Color, align: HorizontalAlignment) -> Label: var label := Label.new() label.text = text label.custom_minimum_size = Vector2(width, 34) label.horizontal_alignment = align label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _applyTextStyle(label, fontSize, color) return label func _applyTextStyle(control: Control, fontSize: int, color: Color) -> void: control.add_theme_font_override("font", FONT_UI) control.add_theme_font_size_override("font_size", fontSize) control.add_theme_color_override("font_color", color) func _applyTransparentButtonText(button: Button) -> void: button.add_theme_color_override("font_color", Color(1, 1, 1, 0)) button.add_theme_color_override("font_disabled_color", Color(1, 1, 1, 0)) button.add_theme_color_override("font_hover_color", Color(1, 1, 1, 0)) button.add_theme_color_override("font_pressed_color", Color(1, 1, 1, 0)) func _tabSlotBounds(categoryId: String, tabIndex: int) -> Vector2: if TAB_SLOT_BOUNDS.has(categoryId): return TAB_SLOT_BOUNDS[categoryId] as Vector2 var slotWidth: float = (_tabsRow.size.x if is_instance_valid(_tabsRow) and _tabsRow.size.x > 0.0 else 1334.0) / 7.0 return Vector2(tabIndex * slotWidth + 8.0, (tabIndex + 1) * slotWidth - 8.0) func _tabLabelOffset(categoryId: String) -> Vector2: match categoryId: "weekly_commits": return Vector2.ZERO "night_owl": return Vector2.ZERO "popularity": return Vector2.ZERO "productive": return Vector2.ZERO "social": return Vector2.ZERO "rising": return Vector2.ZERO "comprehensive": return Vector2.ZERO _: return Vector2.ZERO func _tabLabelTexture(categoryId: String) -> Texture2D: match categoryId: "weekly_commits": return TEX_TAB_LABEL_WEEKLY "night_owl": return TEX_TAB_LABEL_NIGHT "popularity": return TEX_TAB_LABEL_POPULARITY "productive": return TEX_TAB_LABEL_PRODUCTIVE "social": return TEX_TAB_LABEL_SOCIAL "rising": return TEX_TAB_LABEL_RISING "comprehensive": return TEX_TAB_LABEL_COMPREHENSIVE _: return TEX_TAB_LABEL_WEEKLY func _createTabSelectedStyle() -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = Color(0.060, 0.355, 0.655, 0.88) style.border_color = Color(0.690, 0.840, 1.0, 0.58) style.border_width_left = 2 style.border_width_top = 2 style.border_width_right = 2 style.border_width_bottom = 2 style.corner_radius_top_left = 8 style.corner_radius_top_right = 8 style.corner_radius_bottom_left = 5 style.corner_radius_bottom_right = 5 return style func _rankColor(rank: int) -> Color: if rank == 1: return GOLD if rank == 2: return SILVER if rank == 3: return BRONZE return ACCENT_COLOR func _rankFillColor(rank: int) -> Color: if rank == 1: return Color(1.0, 0.790, 0.260, 0.94) if rank == 2: return Color(0.760, 0.830, 0.910, 0.94) if rank == 3: return Color(0.890, 0.520, 0.285, 0.94) return Color(0.250, 0.570, 0.800, 0.94) func _rankMedalTexture(rank: int) -> Texture2D: if rank == 1: return TEX_MEDAL_GOLD if rank == 2: return TEX_MEDAL_SILVER return TEX_MEDAL_BRONZE func _createTabStyle(selected: bool) -> StyleBoxTexture: var style := StyleBoxTexture.new() style.texture = TEX_TAB_ACTIVE if selected else TEX_TAB_NORMAL style.modulate_color = Color(0.960, 0.760, 0.360, 0.0) return style func _createTabFlatStyle(selected: bool) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = Color(0.115, 0.420, 0.690, 1.0) if selected else Color(0.965, 0.875, 0.665, 0.98) style.border_color = Color(0.870, 0.625, 0.265, 1.0) if selected else Color(0.710, 0.455, 0.170, 0.86) style.border_width_left = 2 style.border_width_top = 2 style.border_width_right = 2 style.border_width_bottom = 2 style.corner_radius_top_left = 6 style.corner_radius_top_right = 6 style.corner_radius_bottom_left = 4 style.corner_radius_bottom_right = 4 style.content_margin_left = 10 style.content_margin_right = 10 style.content_margin_top = 6 style.content_margin_bottom = 6 return style func _createParchmentStyle(margins: Vector4, contentMargins: Vector4 = Vector4(0, 0, 0, 0)) -> StyleBoxTexture: return _createTextureStyle(TEX_PARCHMENT_PANEL, margins, contentMargins) func _createTextureStyle(texture: Texture2D, margins: Vector4, contentMargins: Vector4 = Vector4(0, 0, 0, 0), modulate: Color = Color.WHITE) -> StyleBoxTexture: var style := StyleBoxTexture.new() style.texture = texture style.modulate_color = modulate style.axis_stretch_horizontal = StyleBoxTexture.AXIS_STRETCH_MODE_STRETCH style.axis_stretch_vertical = StyleBoxTexture.AXIS_STRETCH_MODE_STRETCH style.texture_margin_left = margins.x style.texture_margin_top = margins.y style.texture_margin_right = margins.z style.texture_margin_bottom = margins.w style.content_margin_left = contentMargins.x style.content_margin_top = contentMargins.y style.content_margin_right = contentMargins.z style.content_margin_bottom = contentMargins.w return style func _createIcon(texture: Texture2D, size: Vector2) -> TextureRect: var icon := TextureRect.new() icon.custom_minimum_size = size icon.texture = texture icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED return icon func _createIconText(iconTexture: Texture2D, label: Label, iconSize: Vector2, separation: int) -> Control: var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_BEGIN row.add_theme_constant_override("separation", separation) row.add_child(_createIcon(iconTexture, iconSize)) row.add_child(label) return row func _createMetricIconText(iconTexture: Texture2D, text: String, width: float, iconSize: float) -> Control: var row := HBoxContainer.new() row.custom_minimum_size = Vector2(width, 38) row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 5) row.add_child(_createIcon(iconTexture, Vector2(iconSize, iconSize))) row.add_child(_createFixedLabel(text, maxf(42.0, width - iconSize - 12.0), 19, DEEP_BLUE, HORIZONTAL_ALIGNMENT_LEFT)) return row func _createScoreMetricText(caption: String, text: String, width: float) -> Control: var row := HBoxContainer.new() row.custom_minimum_size = Vector2(width, 38) row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 5) row.add_child(_createIcon(TEX_ICON_SCORE, Vector2(20, 20))) row.add_child(_createFixedLabel(caption, 50, 14, MUTED_COLOR, HORIZONTAL_ALIGNMENT_LEFT)) row.add_child(_createFixedLabel(text, maxf(44.0, width - 20.0 - 50.0 - 18.0), 19, DEEP_BLUE, HORIZONTAL_ALIGNMENT_LEFT)) return row func _scoreCaption(categoryId: String) -> String: match categoryId: "weekly_commits": return "卷王分" "night_owl": return "夜猫分" "popularity": return "人气分" "productive": return "多产分" "social": return "社交分" "rising": return "新星分" "comprehensive": return "综合分" _: return "荣誉分" func _contribLabel(categoryId: String) -> String: match categoryId: "weekly_commits": return "总贡献" "night_owl": return "总贡献" "popularity": return "总贡献" "productive": return "总贡献" "social": return "总贡献" "rising": return "总贡献" "comprehensive": return "总贡献" _: return "总贡献" func _repoLabel(categoryId: String) -> String: match categoryId: "weekly_commits": return "本周仓" "night_owl": return "深夜仓" "popularity": return "组织仓" "productive": return "组织仓" "social": return "组织仓" "rising": return "组织仓" "comprehensive": return "组织仓" _: return "仓库" func _starLabel(categoryId: String) -> String: match categoryId: "popularity": return "星标" "rising": return "星标" "comprehensive": return "星标" _: return "星标" func _rankingRuleText(categoryId: String) -> String: match categoryId: "weekly_commits": return "计算规则:近 7 天 commit 数 + 活跃天数奖励 + 多仓库奖励 + 日均提交质量奖励" "night_owl": return "计算规则:深夜 22:00-06:00 commit 数 × 2 + 深夜比例奖励 + 活跃天数奖励 + 多仓库奖励" "popularity": return "计算规则:GitHub Followers × 0.6 + 参与组织仓库总 Stars × 0.4" "productive": return "计算规则:参与 Datawhale 组织仓库数量,仓库越多排名越高" "social": return "计算规则:GitHub Following 数量,关注互动越多排名越高" "rising": return "计算规则:(Followers + 组织仓库总 Stars) / 参与仓库数 × 新人加成" "comprehensive": return "计算规则:Stars × 0.3 + Followers × 0.25 + 仓库数 × 0.2 + Following × 0.15 + 贡献数 × 0.1" _: return "计算规则:依据当前榜单维度综合计算排名" func _podiumSlotBounds(rank: int) -> Vector2: if PODIUM_SLOT_BOUNDS.has(rank): return PODIUM_SLOT_BOUNDS[rank] as Vector2 return Vector2(492, 920) func _podiumMetaLine(data: Dictionary) -> String: var location := str(data.get("location", "")).strip_edges() var domain := str(data.get("domain", "")).strip_edges() if location.is_empty() and domain.is_empty(): return "Datawhale 成员" if location.is_empty(): return domain if domain.is_empty(): return location return "%s · %s" % [location, domain] func _podiumTags(data: Dictionary) -> Array[String]: var tags: Array[String] = [] var domainsVariant: Variant = data.get("domains", []) if domainsVariant is Array: for item in domainsVariant as Array: var domain := str(item).strip_edges() if not domain.is_empty() and not tags.has(domain): tags.append(domain) if tags.size() >= 3: break if tags.is_empty(): var domain := str(data.get("domain", "")).strip_edges() if not domain.is_empty(): tags.append(domain) while tags.size() > 3: tags.pop_back() return tags func _avatarPrimaryTextureFromData(data: Dictionary) -> Texture2D: var avatarBase64 := _firstNonEmptyString(data, ["avatarBase64", "avatar_base64"]) if not avatarBase64.is_empty(): var textureFromBase64 := _textureFromBase64(avatarBase64) if textureFromBase64 != null: return textureFromBase64 var localPath := _firstNonEmptyString(data, ["avatarTexture", "avatarTexturePath", "avatar_texture", "avatar_texture_path", "avatarLocalPath"]) if not localPath.is_empty(): var loaded := load(localPath) as Texture2D if loaded != null: var loadedImage := loaded.get_image() if loadedImage != null: return _createCircularAvatarTexture(loadedImage) return null func _avatarUrlFromData(data: Dictionary) -> String: var avatarUrl := _firstNonEmptyString(data, ["avatarUrl", "avatar_url", "avatar", "avatarURL"]) if not avatarUrl.begins_with("/"): return avatarUrl var apiBaseUrl := NetworkConfig.get_api_base_url() if avatarUrl.begins_with("/api/") and apiBaseUrl.ends_with("/api"): return apiBaseUrl.trim_suffix("/api") + avatarUrl return apiBaseUrl + avatarUrl func _firstNonEmptyString(data: Dictionary, keys: Array[String]) -> String: for key in keys: var value := str(data.get(key, "")).strip_edges() if not value.is_empty(): return value return "" func _textureFromBase64(base64Text: String) -> Texture2D: var bytes := Marshalls.base64_to_raw(base64Text.strip_edges()) if bytes.is_empty(): return null var image := Image.new() var err := image.load_png_from_buffer(bytes) if err != OK: err = image.load_jpg_from_buffer(bytes) if err != OK: return null return _createCircularAvatarTexture(image) func _requestAvatarTexture(url: String, textureRect: TextureRect, fallbackLabel: Label, fallbackTexture: Texture2D) -> void: var request := HTTPRequest.new() request.timeout = 10.0 request.request_completed.connect(_onAvatarRequestCompleted.bind(textureRect, fallbackLabel, fallbackTexture, request)) _avatarRequests.append(request) add_child(request) var err := request.request(url) if err != OK: textureRect.texture = fallbackTexture fallbackLabel.visible = fallbackTexture == null _avatarRequests.erase(request) request.queue_free() func _onAvatarRequestCompleted(result: int, responseCode: int, headers: PackedStringArray, body: PackedByteArray, textureRect: TextureRect, fallbackLabel: Label, fallbackTexture: Texture2D, request: HTTPRequest) -> void: _avatarRequests.erase(request) request.queue_free() if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300: if is_instance_valid(textureRect): textureRect.texture = fallbackTexture if is_instance_valid(fallbackLabel): fallbackLabel.visible = fallbackTexture == null return if not is_instance_valid(textureRect): return var image := Image.new() var err := _loadAvatarImageFromResponse(image, body, headers) if err != OK: textureRect.texture = fallbackTexture if is_instance_valid(fallbackLabel): fallbackLabel.visible = fallbackTexture == null return textureRect.texture = _createCircularAvatarTexture(image) if is_instance_valid(fallbackLabel): fallbackLabel.visible = false func _createCircularAvatarTexture(sourceImage: Image) -> Texture2D: var sourceWidth := sourceImage.get_width() var sourceHeight := sourceImage.get_height() if sourceWidth <= 0 or sourceHeight <= 0: return null var cropSize := mini(sourceWidth, sourceHeight) var cropOrigin := Vector2i((sourceWidth - cropSize) / 2, (sourceHeight - cropSize) / 2) var outputSize := 96 var cropped := Image.create(cropSize, cropSize, false, Image.FORMAT_RGBA8) if sourceImage.get_format() != Image.FORMAT_RGBA8: sourceImage.convert(Image.FORMAT_RGBA8) cropped.blit_rect(sourceImage, Rect2i(cropOrigin, Vector2i(cropSize, cropSize)), Vector2i.ZERO) cropped.resize(outputSize, outputSize, Image.INTERPOLATE_LANCZOS) cropped.convert(Image.FORMAT_RGBA8) var radius := float(outputSize) * 0.5 var center := Vector2(radius, radius) var edge := 2.0 for y in range(outputSize): for x in range(outputSize): var distance := Vector2(float(x) + 0.5, float(y) + 0.5).distance_to(center) if distance <= radius - edge: continue var color := cropped.get_pixel(x, y) var alphaScale := clampf((radius - distance) / edge, 0.0, 1.0) color.a *= alphaScale cropped.set_pixel(x, y, color) return ImageTexture.create_from_image(cropped) func _loadAvatarImageFromResponse(image: Image, body: PackedByteArray, headers: PackedStringArray) -> Error: if body.size() >= 8 and body[0] == 0x89 and body[1] == 0x50 and body[2] == 0x4e and body[3] == 0x47: return image.load_png_from_buffer(body) if body.size() >= 3 and body[0] == 0xff and body[1] == 0xd8 and body[2] == 0xff: return image.load_jpg_from_buffer(body) if body.size() >= 12 and body[0] == 0x52 and body[1] == 0x49 and body[2] == 0x46 and body[3] == 0x46 and body[8] == 0x57 and body[9] == 0x45 and body[10] == 0x42 and body[11] == 0x50: return image.load_webp_from_buffer(body) var contentType := _responseContentType(headers) if contentType.contains("jpeg") or contentType.contains("jpg"): var jpgError := image.load_jpg_from_buffer(body) if jpgError == OK: return OK if contentType.contains("webp"): var webpError := image.load_webp_from_buffer(body) if webpError == OK: return OK if contentType.contains("png"): var pngError := image.load_png_from_buffer(body) if pngError == OK: return OK return image.load_png_from_buffer(body) func _responseContentType(headers: PackedStringArray) -> String: for header in headers: var lower := header.to_lower() if lower.begins_with("content-type:"): return lower.get_slice(":", 1).strip_edges() return "" func _clearAvatarRequests() -> void: for request in _avatarRequests: if is_instance_valid(request): request.cancel_request() request.queue_free() _avatarRequests.clear() func _createAvatarFrameStyle(radius: int) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = Color(0.996, 0.875, 0.620, 0.96) style.border_color = Color(0.580, 0.365, 0.145, 0.92) style.border_width_left = 2 style.border_width_top = 2 style.border_width_right = 2 style.border_width_bottom = 2 style.corner_radius_top_left = radius style.corner_radius_top_right = radius style.corner_radius_bottom_left = radius style.corner_radius_bottom_right = radius return style func _createPanelStyle(fill: Color, radius: int, border: Color, borderWidth: int, shadow: bool) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = fill style.corner_radius_top_left = radius style.corner_radius_top_right = radius style.corner_radius_bottom_right = radius style.corner_radius_bottom_left = radius style.border_width_left = borderWidth style.border_width_top = borderWidth style.border_width_right = borderWidth style.border_width_bottom = borderWidth style.border_color = border if shadow: style.shadow_color = Color(0.030, 0.150, 0.320, 0.20) style.shadow_size = 16 style.shadow_offset = Vector2(0, 7) return style func _formatNumber(value: int) -> String: var raw := str(value) var output := "" var count := 0 for index in range(raw.length() - 1, -1, -1): if count > 0 and count % 3 == 0: output = "," + output output = raw[index] + output count += 1 return output func _compactNumber(value: int) -> String: if value >= 1000000: return "%.1fM" % (float(value) / 1000000.0) if value >= 10000: return "%.1f万" % (float(value) / 10000.0) return str(value) func _clearChildren(node: Node) -> void: for child in node.get_children(): child.queue_free() func _urlEncode(value: String) -> String: return value.uri_encode() func _onClosePressed() -> void: get_tree().paused = false queue_free() func _disableChatUiMouseInput() -> void: var currentScene := get_tree().current_scene if currentScene != null: _chatUi = currentScene.get_node_or_null("UILayer/ChatUI") as Control if _chatUi == null: _chatUi = get_tree().root.find_child("ChatUI", true, false) as Control if _chatUi != null: _chatUiPrevMouseFilter = _chatUi.mouse_filter _chatUi.mouse_filter = Control.MOUSE_FILTER_IGNORE _chatUiMouseDisabled = true func _restoreChatUiMouseInput() -> void: if _chatUiMouseDisabled and is_instance_valid(_chatUi): _chatUi.mouse_filter = _chatUiPrevMouseFilter _chatUiMouseDisabled = false _chatUi = null