UI/RankingsPanel.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using Sandbox.UI;

/// <summary>
/// 排行页(v0.7.8.19 用户需求):主菜单 [5] RANKINGS——全球 XP 榜。
/// Leaderboards.GetFromStat("cr_xp") 默认 Sum 降序全时段,每次打开即刷新;
/// 底部自己的等级行(GameXp 本地缓存)。BACK / [4] 回主菜单(与 RoomBrowser 同键位)。
/// 纯 C# Panel + 同目录 RankingsPanel.cs.scss。
/// </summary>
public sealed class RankingsPanel : PanelComponent
{
	const int RowCount = 30;   // 榜容量:装进滚动容器,云端给多少显示多少,滚轮看完整榜

	// 排版预览(2026-09-09 用户调样式用):不查云端,直接摆假榜行,看完改回 false
	// (static readonly 不用 const:const true 会把后面的云查询判成不可达代码,报 CS0162 警告)
	static readonly bool ShowSampleBoard = false;
	static readonly string[] SampleBoard =
	{
		"#1   FLYBIRD   —   125400 XP",
		"#2   SNACKLORD   —   118220 XP",
		"#3   大球王   —   98050 XP",
		"#4   KITTEN_92   —   87310 XP",
		"#5   ProEater   —   76004 XP",
		"#6   汤圆   —   64988 XP",
		"#7   Wobble   —   58310 XP",
		"#8   小小鸟   —   52777 XP",
		"#9   Mochi   —   46002 XP",
		"#10   pixel_cat   —   41388 XP",
		"#11   GULP   —   35240 XP",
		"#12   布丁   —   30117 XP",
		"#13   Doodle   —   26455 XP",
		"#14   泥鳅   —   21980 XP",
		"#15   Bubbles   —   18302 XP",
		"#16   阿翔   —   15044 XP",
	};

	Label _status;
	Label[] _rows;
	Panel _list;               // 排行榜滚动容器(原生网页式滚动)
	Label _self;
	bool _built;
	bool _shown;
	bool _retryOpen;
	int _refreshSeq;   // 每次查询自增:关页/重开后旧响应直接丢弃,防旧数据覆盖新状态
	readonly string[] _lastRows = new string[RowCount];   // 上次成功渲染的榜行快照:第二次打开秒显旧榜,后台刷新拿到新数据才整批替换
	bool _hasSnapshot;

	protected override void OnStart()
	{
		base.OnStart();
		BuildUi();
		Hide();
	}

	void BuildUi()
	{
		var root = Panel;
		if ( root is null ) return;
		if ( _built && _status.IsValid() && root.ChildrenCount > 0 ) return;

		root.DeleteChildren( true );

		root.Style.Position = PositionMode.Absolute;
		root.Style.Left = 0f;
		root.Style.Top = 0f;
		root.Style.Right = 0f;
		root.Style.Bottom = 0f;
		root.Style.BackgroundColor = new Color( 0.008f, 0.012f, 0.05f, 0.55f );
		root.Style.PointerEvents = PointerEvents.All;

		UiKit.AttachStyles( root );   // 共享部件样式(.cr-pill 药丸按钮)

		var title = new Label() { Classes = "rk-title" };
		title.Text = "RANKINGS";
		root.AddChild( title );

		var head = new Label() { Classes = "rk-head" };
		head.Text = "RANK                    PLAYER                          XP";
		root.AddChild( head );

		// 排行榜滚动列表(原生 overflow-y + 滚轮):行牌流内单列,宽/起点/行距的用户调校值挪进 scss .rk-list
		_list = new Panel() { Classes = "rk-list" };
		root.AddChild( _list );

		_rows = new Label[RowCount];
		for ( int i = 0; i < _rows.Length; i++ )
		{
			var row = new Label() { Classes = "rk-row" };
			_list.AddChild( row );
			_rows[i] = row;
		}

		_self = new Label() { Classes = "rk-self" };
		root.AddChild( _self );

		// 状态条(读取中/无数据/失败)必须后于行牌创建——绘制顺序压在空牌上面,否则被 note 卡盖住
		_status = new Label() { Classes = "rk-status" };
		root.AddChild( _status );

		UiKit.PillButton( root, "BACK", "rk-back", () => OnBackClicked() );   // 捕获 this:热重载可重映射

		_built = true;
	}

	/// <summary> 主菜单 [5] 进入:开门即查榜 </summary>
	public void Show()
	{
		BuildUi();
		if ( !_built )
		{
			_retryOpen = true;
			return;
		}

		_retryOpen = false;
		_shown = true;
		Panel.Style.Display = DisplayMode.Flex;
		RefreshBoard();
	}

	public void Hide()
	{
		_shown = false;
		_retryOpen = false;
		if ( Panel.IsValid() ) Panel.Style.Display = DisplayMode.None;
	}

	/// <summary> 页面是否开着(ReconcileUi 终态判定用) </summary>
	public bool IsOpen => _shown;

	protected override void OnUpdate()
	{
		base.OnUpdate();

		BuildUi();

		if ( _retryOpen && !_shown )
		{
			Show();
			return;
		}

		if ( !_shown ) return;

		if ( Panel.IsValid() && Panel.Style.Display != DisplayMode.Flex ) Panel.Style.Display = DisplayMode.Flex;   // 同值守卫(v0.7.8.87)
		Mouse.Visibility = MouseVisibility.Visible;

		// 自己的等级行(云端未加载时 GameXp 内部 5s 节流重试)
		GameXp.EnsureLocalXp();
		if ( _self.IsValid() )
		{
			var t = GameXp.LevelText();
			SetText( _self, t.Length > 0 ? t : "STATS LOADING ..." );   // 防抖:每帧裸写 Text = 排行榜打开期间整页闪烁
		}

		if ( Input.Pressed( "Slot4" ) ) OnBackClicked();
	}

	static void SetText( Label l, string text )
	{
		if ( l is null || !l.IsValid() || l.Text == text ) return;
		l.Text = text;
	}

	void SetStatus( string text ) => SetText( _status, text );

	/// <summary>
	/// 查全球 XP 榜(默认 Sum 降序全时段)。
	/// 三个实测坑:①"Refresh 完成"和"数据真填进 Entries"不同时刻到,常先回空结果——0 条原地重试(共 3 次);
	/// ②同一榜对象第二次 Refresh 可能返回空/失败——**所以绝不先清旧行**,有快照先摆快照秒显,新数据到了才替换;
	/// ③从没成功渲染过才允许显示 NO SCORES。
	/// </summary>
	async void RefreshBoard()
	{
		_refreshSeq++;
		int seq = _refreshSeq;

		// 排版预览:摆假榜行就走,不查云端(看完关掉 ShowSampleBoard)
		if ( ShowSampleBoard )
		{
			for ( int i = 0; i < _rows.Length; i++ )
			{
				var text = i < SampleBoard.Length ? SampleBoard[i] : "";
				SetText( _rows[i], text );
				_lastRows[i] = text;
			}
			_hasSnapshot = true;
			SetStatus( "" );
			return;
		}

		// 有旧榜先摆旧榜(第二次打开不再空白),LOADING 只是角标,新数据到了才整批替换
		if ( _hasSnapshot )
			for ( int i = 0; i < _rows.Length; i++ ) SetText( _rows[i], _lastRows[i] );

		SetStatus( "LOADING LEADERBOARD ..." );

		for ( int attempt = 0; attempt < 3; attempt++ )
		{
			try
			{
				var board = Sandbox.Services.Leaderboards.GetFromStat( GameXp.StatName );
				board.SetAggregationSum();
				board.FilterByNone();
				await board.Refresh( CancellationToken.None );

				if ( !_shown || seq != _refreshSeq ) return;   // 关页/重开:响应作废

				var entries = board.Entries;
				GameLog.Info( $"[rank] refresh attempt {attempt + 1}: {entries?.Length ?? 0} entries" );

				if ( entries is not null && entries.Length > 0 )
				{
					RenderRows( entries );
					SetStatus( "" );
					return;
				}

				// 空结果:等 2 秒再拉一次(数据经常晚于"完成"到达),旧榜/LOADING 保持
				if ( attempt < 2 ) await Task.Delay( 2000 );
			}
			catch ( Exception e )
			{
				Log.Warning( $"[rank] leaderboard refresh failed: {e.Message}" );
				break;   // 出错不再重试,落到兜底提示
			}
		}

		// 全部落空:成功渲染过 → 保留旧榜撤提示;从没成功过 → 无数据提示
		if ( _shown && seq == _refreshSeq )
			SetStatus( _hasSnapshot ? "" : "NO SCORES YET — COME BACK LATER" );
	}

	void RenderRows( Sandbox.Services.Leaderboards.Board2.Entry[] entries )
	{
		if ( entries is null ) entries = new Sandbox.Services.Leaderboards.Board2.Entry[0];

		for ( int i = 0; i < _rows.Length; i++ )
		{
			if ( i >= entries.Length )
			{
				SetText( _rows[i], "" );
				_lastRows[i] = "";
				continue;
			}

			var e = entries[i];
			var text = $"#{e.Rank}   {e.DisplayName}   —   {e.Value} XP";
			SetText( _rows[i], text );
			_lastRows[i] = text;
		}

		_hasSnapshot = true;
	}

	void OnBackClicked()
	{
		if ( !_shown ) return;
		CircleroyaleGame.Current?.CloseRankings();
	}
}