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;
Image _crown; // 冠军皇冠(v0.7.8.38 像素风):悬在 winner 行上方居中
Label _matchHead;
Label[] _matchRows;
Label _selfRow;
Label _globalHead;
Label[] _globalRows;
Label _globalNote;
Label _back;
bool _built;
bool _shown;
TimeSince _sinceShow;
int _backSecs = -1; // 返回按钮上次显示的秒数(变了才重拼文本,v0.7.8.87)
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" );
_crown = new Image();
_crown.Texture = Texture.Load( "ui/pixel/crown.png" );
_crown.Style.Position = PositionMode.Absolute;
_crown.Style.Width = 22f;
_crown.Style.Height = 22f;
_crown.Style.Display = DisplayMode.None;
root.AddChild( _crown );
_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/width 是 1920 设计稿坐标,换算成"中心锚定 + 相对偏移"——
/// 任意分辨率下整体保持居中(left:960 定位在窄窗口会整体偏右,实测) </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 = Length.Percent( 50f );
l.Style.MarginLeft = left - 960f;
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;
_backSecs = -1; // 强制重拼返回按钮文本(v0.7.8.87)
_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})";
// 领土战争(M7.4 积分制):胜负按占旗积分(host 排序同源,这里只取文案)
if ( MatchState.IsTerritory )
{
int bestT = -1;
float bestPts = -1f;
for ( var t = 0; t < 4; t++ )
{
var pts = TerritoryManager.Points( t );
if ( pts > bestPts )
{
bestPts = pts;
bestT = t;
}
}
return $"TEAM {bestT + 1} WINS ({bestPts:0} PTS)";
}
// 团队赛:胜负按队伍 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;
if ( rows is null || rows.Length == 0 )
{
// 无名单(取消局/空结算):清行而不是 NRE(v0.7.8.87;WinnerText 早已按 null 防过)
for ( int i = 0; i < _matchRows.Length; i++ )
{
if ( _matchRows[i].IsValid() && _matchRows[i].Text.Length > 0 ) _matchRows[i].Text = "";
}
if ( _selfRow.IsValid() && _selfRow.Style.Display != DisplayMode.None ) _selfRow.Style.Display = DisplayMode.None;
return;
}
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 ( row is null || !row.IsValid() ) continue; // 过期引用不可写(v0.7.8.87)
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.898f, 0.282f, 0.553f ) : new Color( 0.29f, 0.23f, 0.39f, 0.85f );
}
// 榜外自身行
bool showSelf = selfRank >= _matchRows.Length;
if ( _selfRow.IsValid() )
{
_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 ) Panel.Style.Display = DisplayMode.Flex; // 同值守卫(v0.7.8.87)
Mouse.Visibility = MouseVisibility.Visible; // 结算期按钮要能点
// 空格提前返回(按钮文案写着 [SPACE]):放在入场动画之前,按下即响应。
// MatchOver 后 Tick/Ball.OnUpdate 都不再跑,这里不接就没人接了。
// BallInput.Typing 守卫:聊天框聚焦时按键会无差别进 InputContext,打字打空格不能退局
if ( !BallInput.Typing && Input.Pressed( "Jump" ) )
{
CircleroyaleGame.Current?.AfterSettlement();
return;
}
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 );
// 皇冠悬在冠军行正上方(winner 是定宽居中行,文字中心恒 960;透明度随其入场动画)
if ( _crown.IsValid() )
{
var show = _winner.IsValid() && !string.IsNullOrEmpty( _winner.Text );
var crownMode = show ? DisplayMode.Flex : DisplayMode.None;
if ( _crown.Style.Display != crownMode ) _crown.Style.Display = crownMode;
if ( show )
{
// 位置/锚点常量只在首帧写一次(每帧同值写=布局重算,v0.7.8.87);Top 是浮动动画,按阈值写
if ( !SamePx( _crown.Style.MarginLeft, -11f ) )
{
_crown.Style.Left = Length.Percent( 50f ); // 冠军行中心锚定后皇冠跟随面板中心
_crown.Style.MarginLeft = -11f; // 22px 宽的半个
}
var top = 138f + MathF.Sin( t * 3f ) * 4f;
if ( !SamePx( _crown.Style.Top, top ) ) _crown.Style.Top = top;
if ( !SameFloat( _crown.Style.Opacity, _winner.Style.Opacity ?? 1f ) ) _crown.Style.Opacity = _winner.Style.Opacity;
}
}
// 自动返回(v0.6.4.2):结算显示 10 秒后回大厅(多人)/主菜单(单机),空格可提前
var remain = GameConfig.SettlementAutoReturnSeconds - _sinceShow;
if ( remain <= 0f )
{
CircleroyaleGame.Current?.AfterSettlement();
return;
}
if ( _back.IsValid() )
{
// 秒数变了才重拼文本(每帧插值=每帧一次字符串分配 + 布局写,v0.7.8.87)
var secs = (int)MathF.Ceiling( remain );
if ( secs != _backSecs )
{
_backSecs = secs;
_back.Text = Networking.IsActive
? $"RETURN TO LOBBY IN {secs} — [SPACE NOW]"
: $"BACK TO MENU IN {secs} — [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 );
// 动画跑完(k=1)后不再写样式:结算要挂 10 秒,同值写会一直强迫布局(v0.7.8.87)
if ( k >= 1f )
{
if ( !SameFloat( row.Style.Opacity, 1f ) ) row.Style.Opacity = 1f;
if ( !SamePx( row.Style.PaddingLeft, 0f ) ) row.Style.PaddingLeft = 0f;
return;
}
if ( k <= 0f )
{
if ( !SameFloat( row.Style.Opacity, 0f ) ) row.Style.Opacity = 0f;
return;
}
row.Style.Opacity = k; // 动画进行中逐帧变化,必须写
row.Style.PaddingLeft = ( 1f - k ) * 36f;
}
// ---- 样式属性都是 nullable(Length?/float?):同值守卫的 null 安全比较("未设置"算不同 → 需要写) ----
/// <summary> 像素长度是否已等于 px(未设置/单位不是 px 都算不同) </summary>
static bool SamePx( Length? len, float px ) =>
len.HasValue && len.Value.Unit == LengthUnit.Pixels && MathF.Abs( len.Value.Value - px ) < 0.5f;
/// <summary> 浮点样式(Opacity 等)是否已等于目标值 </summary>
static bool SameFloat( float? v, float target, float eps = 0.01f ) =>
v.HasValue && MathF.Abs( v.Value - target ) < eps;
}