UI/EndBoard.cs
using System;
using System.Collections.Generic;
using Sandbox.UI;
/// <summary>
/// 结算面板(M5):比赛结束全屏结算——本局排行(逐行滑入动画)+ 全服排行
/// (云端优先 ScoreUploader,空了降级 LocalBoard 本地榜)+ 回菜单按钮。
/// 纯 C# Panel + 同目录 EndBoard.cs.scss;显示期间全场模拟冻结(MatchState.MatchOver)。
/// </summary>
public sealed class EndBoard : PanelComponent
{
Label _title;
Label _winner;
Label _matchHead;
Label[] _matchRows;
Label _selfRow;
Label _globalHead;
Label[] _globalRows;
Label _globalNote;
Label _back;
bool _built;
bool _shown;
TimeSince _sinceShow;
ScoreWire[] _standings;
long _mySteamId;
// 逐行 reveal 动画:行索引 → 出场时刻(秒),透明度/位移在 0.25s 内渐入
const float MatchRowDelay = 0.45f;
const float MatchRowStagger = 0.22f;
const float GlobalDelay = 3.4f;
const float GlobalStagger = 0.16f;
protected override void OnStart()
{
base.OnStart();
BuildUi();
Hide();
}
void BuildUi()
{
var root = Panel;
if ( root is null ) return;
if ( _built && _title.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.88f );
root.Style.PointerEvents = PointerEvents.All;
// 标题/冠军/回菜单居中;本局排行(左列)与全服排行(右列)并排(v0.6.3.0 用户定稿)
_title = NewRow( root, 88f, 360f, 1200f, "eb-title" );
_title.Text = "MATCH OVER";
_winner = NewRow( root, 166f, 360f, 1200f, "eb-winner" );
_matchHead = NewRow( root, 244f, 260f, 580f, "eb-head" );
_matchHead.Text = "MATCH RANKING";
// 本局排行:10 行 + 榜外自身行(左列)
_matchRows = new Label[GameConfig.LeaderboardRows];
for ( int i = 0; i < _matchRows.Length; i++ )
{
_matchRows[i] = NewRow( root, 276f + i * 28f, 260f, 580f, "eb-row" );
}
_selfRow = NewRow( root, 276f + _matchRows.Length * 28f, 260f, 580f, "eb-row self" );
_selfRow.Style.Display = DisplayMode.None;
_globalHead = NewRow( root, 244f, 1060f, 580f, "eb-head global" );
_globalHead.Text = "GLOBAL TOP 10";
_globalRows = new Label[10];
for ( int i = 0; i < _globalRows.Length; i++ )
{
_globalRows[i] = NewRow( root, 276f + i * 28f, 1060f, 580f, "eb-row small" );
}
_globalNote = NewRow( root, 276f + _globalRows.Length * 28f, 1060f, 580f, "eb-note" );
_back = NewRow( root, 620f, 360f, 1200f, "eb-back" );
_back.Text = Networking.IsActive ? "RETURN TO LOBBY — [SPACE]" : "BACK TO MENU — [SPACE]";
_back.AddEventListener( "onclick", () => OnBackClicked() );
_built = true;
}
/// <summary> 建一行 Label:列内居中(left..left+width),行号定 top </summary>
Label NewRow( Panel root, float top, float left, float width, string classes )
{
var l = new Label() { Classes = classes };
l.Style.Position = PositionMode.Absolute;
l.Style.Left = left;
l.Style.Width = width;
l.Style.Top = top;
root.AddChild( l );
return l;
}
/// <summary> 比赛结束展示结算(CircleroyaleGame.OnMatchOverRemote 调用,双端) </summary>
public void Show( ScoreWire[] standings, long mySteamId, string myName )
{
_standings = standings;
_mySteamId = mySteamId;
BuildUi();
if ( !_built ) return;
_sinceShow = 0;
_shown = true;
Panel.Style.Display = DisplayMode.Flex;
_winner.Text = WinnerText( standings );
FillMatchRows();
FillGlobalPlaceholder();
ScoreUploader.FetchGlobal( ApplyGlobal );
}
/// <summary> 回大厅/主菜单(实例方法:热重载可重映射) </summary>
void OnBackClicked() => CircleroyaleGame.Current?.AfterSettlement();
/// <summary> 回大厅时收起(面板随 GameObject 复用,下次开局再展示) </summary>
public void Hide()
{
_shown = false;
if ( Panel.IsValid() ) Panel.Style.Display = DisplayMode.None;
}
// ---- 内容填充 ----
string WinnerText( ScoreWire[] rows )
{
if ( rows is null || rows.Length == 0 ) return "";
if ( !MatchState.IsTeam )
return $"WINNER — {rows[0].Name} ({rows[0].Mass:0})";
// 团队赛:胜负按队伍 3 人总分
var totals = new Dictionary<int, float>();
foreach ( var r in rows )
{
totals.TryGetValue( r.Team, out var m );
totals[r.Team] = m + r.Mass;
}
int best = -1;
float bestMass = -1f;
foreach ( var kv in totals )
{
if ( kv.Value > bestMass )
{
bestMass = kv.Value;
best = kv.Key;
}
}
return $"TEAM {best + 1} WINS ({bestMass:0})";
}
void FillMatchRows()
{
var rows = _standings;
int selfRank = -1;
for ( int i = 0; i < rows.Length; i++ )
{
if ( rows[i].SteamId == _mySteamId && !rows[i].Bot )
{
selfRank = i;
break;
}
}
for ( int i = 0; i < _matchRows.Length; i++ )
{
var row = _matchRows[i];
if ( i >= rows.Length )
{
row.Text = "";
continue;
}
var r = rows[i];
row.Text = RowText( $"{i + 1}.", r.Name, $"{r.Mass:0}", r.Team );
bool isSelf = r.SteamId == _mySteamId && !r.Bot;
row.Style.FontColor = isSelf ? new Color( 0.21f, 0.94f, 1f ) : new Color( 1f, 1f, 1f, 0.78f );
}
// 榜外自身行
bool showSelf = selfRank >= _matchRows.Length;
_selfRow.Style.Display = showSelf ? DisplayMode.Flex : DisplayMode.None;
if ( showSelf )
{
var r = rows[selfRank];
_selfRow.Text = RowText( $"#{selfRank + 1}.", r.Name, $"{r.Mass:0}", r.Team );
}
}
/// <summary> 行文本(团队赛带 T# 前缀;普通赛只有名次/名字/分数) </summary>
string RowText( string rank, string name, string mass, byte team )
{
return MatchState.IsTeam ? $"{rank} [T{team + 1}] {name} {mass}" : $"{rank} {name} {mass}";
}
/// <summary> 全服榜先占位"加载中",云端回来后填充(空了降级本地榜) </summary>
void FillGlobalPlaceholder()
{
_globalRows[0].Text = "LOADING ...";
for ( int i = 1; i < _globalRows.Length; i++ )
_globalRows[i].Text = "";
_globalNote.Text = "";
}
void ApplyGlobal( List<( string Name, float Mass )> entries )
{
if ( !_shown ) return;
if ( _globalRows is null || _globalRows[0] is null || !_globalRows[0].IsValid() ) return;
if ( entries is null )
{
// 云端不可用(未发布包是常态):降级本机持久化榜
entries = LocalBoard.Top( _globalRows.Length );
_globalNote.Text = entries is not null ? "OFFLINE BOARD (THIS MACHINE)" : "NO RECORDS YET";
}
else
{
_globalNote.Text = "BEST SINGLE-MATCH SCORE";
}
for ( int i = 0; i < _globalRows.Length; i++ )
{
if ( entries is null || i >= entries.Count )
{
_globalRows[i].Text = "";
continue;
}
_globalRows[i].Text = $"{i + 1}. {entries[i].Name} {entries[i].Mass:0}";
}
}
// ---- 逐帧:reveal 动画 + 光标 + 空格回菜单 ----
protected override void OnUpdate()
{
base.OnUpdate();
BuildUi();
if ( !_shown ) return;
if ( Panel.IsValid() ) Panel.Style.Display = DisplayMode.Flex;
Mouse.Visibility = MouseVisibility.Visible; // 结算期按钮要能点
float t = _sinceShow;
Animate( _title, t, 0.05f );
Animate( _winner, t, 0.2f );
Animate( _matchHead, t, MatchRowDelay - 0.2f );
for ( int i = 0; i < _matchRows.Length; i++ )
Animate( _matchRows[i], t, MatchRowDelay + i * MatchRowStagger );
Animate( _selfRow, t, MatchRowDelay + _matchRows.Length * MatchRowStagger );
Animate( _globalHead, t, GlobalDelay - 0.2f );
for ( int i = 0; i < _globalRows.Length; i++ )
Animate( _globalRows[i], t, GlobalDelay + i * GlobalStagger );
Animate( _globalNote, t, GlobalDelay + _globalRows.Length * GlobalStagger );
Animate( _back, t, GlobalDelay + _globalRows.Length * GlobalStagger + 0.3f );
// 自动返回(v0.6.4.2):结算显示 10 秒后回大厅(多人)/主菜单(单机),空格可提前
var remain = GameConfig.SettlementAutoReturnSeconds - _sinceShow;
if ( remain <= 0f )
{
CircleroyaleGame.Current?.AfterSettlement();
return;
}
if ( _back.IsValid() )
_back.Text = Networking.IsActive
? $"RETURN TO LOBBY IN {MathF.Ceiling( remain ):0} — [SPACE NOW]"
: $"BACK TO MENU IN {MathF.Ceiling( remain ):0} — [SPACE NOW]";
}
/// <summary> 单行渐入:出场前 Opacity 0;0.25s 内透明度 0→1、PaddingLeft 36px→0(视觉上从右滑到位) </summary>
void Animate( Label row, float now, float revealAt )
{
if ( row is null || !row.IsValid() ) return;
float k = Math.Clamp( ( now - revealAt ) / 0.25f, 0f, 1f );
if ( k <= 0f )
{
row.Style.Opacity = 0f;
return;
}
row.Style.Opacity = k;
row.Style.PaddingLeft = ( 1f - k ) * 36f;
}
}