Files
whale-town-front-v2/scenes/ui/DatawhaleHonorRankingPanel.gd

1378 lines
52 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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